Skip to content

Commit 7a15d0d

Browse files
committed
implement default-off local license check
This is controlled by a build-time compilation macro, OC_LICENSE_PATH By default, this will have a value of 'cli', which will preserve the existing license check behavior of using the automate cli when present. If the macro is set to a file path (this can be done by setting the environment variable OC_LICENSE_PATH to the target location, prior to build) then at run-time, erchef will expect to find a license file in this location. If the specified the license file is missing or invalid, it is treated as a 90 day trial license from time of upgrade to the version that implements this change. When the file is present, the expiration is pulled from the file, based on the entitlement end time furthest in the future. This was chosen because the license content does not directly contain expiration date.
1 parent f17412a commit 7a15d0d

File tree

5 files changed

+173
-40
lines changed

5 files changed

+173
-40
lines changed

src/oc_erchef/apps/chef_license/src/chef_license_worker.erl

Lines changed: 152 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
terminate/2
2020
]).
2121

22+
2223
-record(state, {
2324
scanned_time,
2425
license_type,
@@ -27,16 +28,23 @@
2728
expiration_date,
2829
message,
2930
customer_name,
30-
license_id
31+
license_id,
32+
install_time
3133
}).
3234

3335
-define(DEFAULT_LICENSE_SCAN_INTERVAL, 30000). %milli seconds
34-
3536
-define(DEFAULT_FILE_PATH, "/tmp/lic").
36-
3737
-define(LICENSE_SCAN_CMD, "chef-automate license status --result-json ").
3838

3939

40+
%% Valid options: cli, or a path to a file
41+
%% Note that this is defaulted to cli in erl_opts in rebar.config.script but can be overridden
42+
%% with the path at build time by setting OC_LICENSE_PATH=<path> in the environment.
43+
%% (In that case, path should be something like /var/opt/opscode/license.lic)
44+
-ifndef(OC_LICENSE_PATH).
45+
-define(OC_LICENSE_PATH, cli).
46+
-endif.
47+
4048
%%% ======================================
4149
%%% Exported
4250
%%% ======================================
@@ -51,7 +59,7 @@ get_license() ->
5159
%%% Gen Server callbacks
5260
%%% ======================================
5361
init(_Config) ->
54-
State = check_license(#state{}),
62+
State = check_license(#state{install_time = install_time()}, ?OC_LICENSE_PATH),
5563
erlang:send_after(?DEFAULT_LICENSE_SCAN_INTERVAL, self(), check_license),
5664
{ok, State}.
5765

@@ -66,7 +74,7 @@ handle_cast(_Message, State) ->
6674
{noreply, State}.
6775

6876
handle_info(check_license, State) ->
69-
State1 = check_license(State),
77+
State1 = check_license(State, ?OC_LICENSE_PATH),
7078
erlang:send_after(?DEFAULT_LICENSE_SCAN_INTERVAL, self(), check_license),
7179
{noreply, State1};
7280

@@ -79,35 +87,147 @@ code_change(_OldVsn, State) ->
7987
terminate(_Reason, _State) ->
8088
ok.
8189

90+
%%%
91+
%%% Internal Implementation
92+
%%%
93+
94+
%% Return an approximate installation time based on the unix timestamp found kvpairs
95+
install_time() ->
96+
DefaultTime = os:system_time(second),
97+
case chef_sql:select_rows({value_for_key, [<<"itime">>]}) of
98+
[[{<<"value">>, Itime}]]->
99+
try
100+
binary_to_integer(Itime)
101+
catch error:badarg ->
102+
DefaultTime
103+
end;
104+
not_found -> {ok, 0};
105+
{error, Reason} -> {error, Reason}
106+
end.
107+
108+
% This will continue to be the default behavior - license will only be checked
109+
% as part of an automate install, when the automate CLI is present.
110+
get_license_info(_InstallTime, cli) ->
111+
os:cmd(?LICENSE_SCAN_CMD ++ ?DEFAULT_FILE_PATH),
112+
{ok, Bin} = file:read_file(?DEFAULT_FILE_PATH),
113+
{Json} = jiffy:decode(Bin),
114+
Json;
115+
% This is for the file-based license mode, where we read the license directly from a file path.
116+
get_license_info(InstallTime, Path) ->
117+
case load_local_license(InstallTime, Path) of
118+
{ok, Json} ->
119+
io:format("Data: ~p~n", Json),
120+
Json;
121+
{error, Reason} ->
122+
io:format("Failed to load local license going with default: ~p~n", [Reason]),
123+
make_license_payload(InstallTime, #{})
124+
end.
125+
126+
load_local_license(InstallTime, Loader) when is_function(Loader) ->
127+
case Loader() of
128+
{ok, Bin} ->
129+
%% Input format is the dotted three-part JWT style base64url encoded string, so
130+
%% we start by splitting on dot and ensuring we get the expected three parts
131+
case binary:split(Bin, [<<".">>], [trim_all, global]) of
132+
%% NOTE: This implementation does not verify signature. To implement signature verification,
133+
%% we will need to have the license service public key available to the license worker
134+
%% at a known path.
135+
[_Header, Payload, _Signature] ->
136+
try base64:decode(Payload, #{ padding => false, mode => urlsafe } ) of
137+
Decoded ->
138+
try jiffy:decode(Decoded, [return_maps]) of
139+
J -> {ok, make_license_payload(InstallTime, J)}
140+
catch _:Reason ->
141+
{ error , {invalid_json, Reason } } % %, Decoded } }
142+
end
143+
catch _:Reason ->
144+
{ error, { invalid_base64, Reason } } %, Payload } }
145+
end;
146+
_ ->
147+
{ error, { invalid_license_format } }
148+
end;
149+
{error, Reason} ->
150+
{ error, { load_failed, Reason } }
151+
end;
152+
load_local_license(InstallTime, Path) ->
153+
load_local_license( InstallTime, fun() -> file:read_file(Path) end ).
154+
155+
156+
%% Generates a license payload using a parsed license. The generated license is 's compatiable with the
157+
%% automate-ctl license status json output object, so that we can use the same processing function for both
158+
%% the CLI and file-based license modes.
159+
%% In some cases, we don't have all the data that the license service would provide, so we'll make substitutes.
160+
make_license_payload(_InstallTime, #{ <<"id">> := LicenseId,
161+
<<"customer">> := CustomerName,
162+
<<"entitlements">> := Entitlements,
163+
<<"type">> := LicenseType
164+
}) ->
165+
166+
%% The license server itself would give us actual expiration date. We have to
167+
%% approximate as best we can based the latest end date of available entitilements.
168+
ExpireInSeconds = case Entitlements of
169+
[] -> 0; % No entitlements, treat as expired or invalid license
170+
_ -> lists:max([Ent || #{ <<"end">> := #{<<"seconds">> := Ent}} <- Entitlements])
171+
end,
172+
173+
[ {<<"result">>, {[
174+
{<<"license_id">>, LicenseId},
175+
{<<"customer_name">>, CustomerName},
176+
{<<"expiration_date">>, {[{<<"seconds">>, ExpireInSeconds}]}},
177+
{<<"license_type">>, LicenseType},
178+
{<<"grace_period">>, false}
179+
]}}
180+
];
181+
make_license_payload(InstallTime, _Other) ->
182+
%% If we don't have the expected fields, we don't have a valid license - so let's make something that looks like one,
183+
%% but is valid from the install date + 90 days.
184+
ExpirationTime = InstallTime + (60 * 60 * 24 * 90), % 90 days from install time
185+
lager:warning("License payload is missing expected fields, treating as valid license with expiration : ~p", [ExpirationTime]),
186+
[ {<<"result">>, {[
187+
{<<"license_id">>, <<>>},
188+
{<<"customer_name">>, <<>>},
189+
{<<"expiration_date">>, {[{<<"seconds">>, ExpirationTime}]}},
190+
{<<"license_type">>, <<"commercial">>},
191+
{<<"grace_period">>, false}
192+
]}}
193+
].
194+
195+
196+
197+
%%%
82198
%%% =====================
83199
%%% Internal functions
84200
%%% =====================
85-
check_license(State) ->
86-
JsonStr =
87-
case catch get_license_info() of
88-
Result when is_list(Result) -> Result;
89-
{'EXIT', _} -> <<"">>
90-
end,
91-
case process_license(JsonStr) of
92-
{ok, valid_license, ExpDate, CustomerName, LicenseId} ->
93-
State#state{license_cache=valid_license, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, customer_name=CustomerName, license_id = LicenseId};
94-
{ok, commercial_expired, ExpDate, Msg, CustomerName, LicenseId} ->
95-
State#state{license_cache=commercial_expired, license_type = <<"commercial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
96-
{ok, commercial_grace_period, ExpDate, Msg, CustomerName, LicenseId} ->
97-
State#state{license_cache=commercial_grace_period, grace_period=true, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
98-
{ok, trial_expired, ExpDate, Msg, CustomerName, LicenseId} ->
99-
State#state{license_cache=trial_expired_expired, license_type = <<"trial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
100-
{error, no_license} ->
101-
State#state{license_cache=trial_expired_expired, license_type = <<"trial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date="", message=get_alert_message(trial_expired, "")};
102-
{error, _} -> State
103-
end.
201+
check_license(#state{install_time = InstallTime} = State, ModeOrPath) ->
202+
JsonStr = case catch get_license_info(InstallTime, ModeOrPath) of
203+
Result when is_list(Result) -> Result;
204+
{'EXIT', N} ->
205+
io:format("Oopsie, exited license check ~p~n", [N]),
206+
<<"">>
207+
end,
208+
R2 = process_license(JsonStr),
104209

105-
get_license_info() ->
106-
os:cmd(?LICENSE_SCAN_CMD ++ ?DEFAULT_FILE_PATH),
107-
{ok, Bin} = file:read_file(?DEFAULT_FILE_PATH),
108-
{JsonStr} = jiffy:decode(Bin),
109-
JsonStr.
210+
io:format("process_license result: ~p ~p~n", [JsonStr, R2]),
211+
case process_license(JsonStr) of
212+
{ok, valid_license, ExpDate, CustomerName, LicenseId} ->
213+
State#state{license_cache=valid_license, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, customer_name=CustomerName, license_id = LicenseId};
214+
{ok, commercial_expired, ExpDate, Msg, CustomerName, LicenseId} ->
215+
State#state{license_cache=commercial_expired, license_type = <<"commercial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
216+
{ok, commercial_grace_period, ExpDate, Msg, CustomerName, LicenseId} ->
217+
State#state{license_cache=commercial_grace_period, grace_period=true, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
218+
{ok, trial_expired, ExpDate, Msg, CustomerName, LicenseId} ->
219+
State#state{license_cache=trial_expired_expired, license_type = <<"trial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date=ExpDate, message=Msg, customer_name=CustomerName, license_id = LicenseId};
220+
{error, no_license} ->
221+
State#state{license_cache=trial_expired_expired, license_type = <<"trial">>, grace_period=undefined, scanned_time = erlang:timestamp(), expiration_date="", message=get_alert_message(trial_expired, "")};
222+
{error, _} -> State
223+
end.
110224

225+
226+
% missing license: gets treated as a trial that expires at TRACK_INSTALL_DATE + 90 days (configurable at build)
227+
% a present license that is not valid gets treated as an expired license.
228+
% a valid unexpired license gets treated as valid
229+
% a valid expired license gets treated as expired. license gets treated as valid, and we don not
230+
%
111231
process_license(<<"">>) ->
112232
{error, invalid_json};
113233
process_license(LicJson) ->
@@ -125,10 +245,9 @@ process_license(LicJson) ->
125245
<<"commercial">> ->
126246
case ej:get({<<"grace_period">>}, LicDetails) of
127247
true ->
128-
{ok, commercial_grace_period, ExpDate,
129-
get_alert_message(commercial_grace_period, ExpDate), CustomerName, LicenseId};
248+
{ok, commercial_grace_period, ExpDate, get_alert_message(commercial_grace_period, ExpDate), CustomerName, LicenseId};
130249
_ ->
131-
{ ok, commercial_expired, ExpDate, get_alert_message(commercial_expired, ExpDate), CustomerName, LicenseId}
250+
{ok, commercial_expired, ExpDate, get_alert_message(commercial_expired, ExpDate), CustomerName, LicenseId}
132251
end;
133252
_ ->
134253
{ok, trial_expired, ExpDate, get_alert_message(trial_expired, ExpDate), CustomerName, LicenseId}
@@ -148,6 +267,7 @@ process_license(LicJson) ->
148267
{error, invalid_response}
149268
end.
150269

270+
151271
get_alert_message(Type, ExpDate) ->
152272
case Type of
153273
trial_expired ->

src/oc_erchef/apps/chef_license/test/chef_license_worker_test.erl

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ license_test()->
3131
{Result, _, _, _, _,_,_} = chef_license_worker:get_license(),
3232
?assertEqual(valid_license, Result),
3333
os:cmd("rm -rf " ++ ?DEFAULT_FILE_PATH),
34-
34+
3535
file:write_file(?DEFAULT_FILE_PATH,get_commercial_license_expired()),
3636
refresh_license(),
3737
timer:sleep(100),
3838
{Result1, _, _, _, _,_,_} = chef_license_worker:get_license(),
3939
?assertEqual(commercial_expired, Result1),
4040
os:cmd("rm -rf " ++ ?DEFAULT_FILE_PATH),
41-
41+
4242
file:write_file(?DEFAULT_FILE_PATH,get_commercial_grace_license()),
4343
refresh_license(),
4444
timer:sleep(100),
@@ -61,4 +61,5 @@ license_test()->
6161
os:cmd("rm -rf " ++ ?DEFAULT_FILE_PATH).
6262

6363
refresh_license()->
64-
erlang:send(chef_license_worker, check_license).
64+
erlang:send(chef_license_worker, check_license).
65+

src/oc_erchef/rebar.config

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@
2626
{git, "https://github.com/chef/ej", {branch, "master"}}},
2727
{envy, ".*",
2828
{git, "https://github.com/markan/envy", {branch, "master"}}},
29-
% {eper, ".*",
30-
% {git, "https://github.com/massemanet/eper", {branch, "master"}}},
3129
{erlcloud, ".*",
3230
{git, "https://github.com/chef/erlcloud", {branch, "CHEF-11677/CHEF-12498/lbaker"}}},
3331
{erlware_commons, ".*",
@@ -78,6 +76,8 @@
7876
{d, 'OC_CHEF'},
7977
{d, 'CHEF_DB_DARKLAUNCH', xdarklaunch_req},
8078
{d, 'CHEF_WM_DARKLAUNCH', xdarklaunch_req},
79+
% Note: OC_LICENSE_PATH value is set dynamically in rebar.config.script
80+
% {d, OC_LICENSE_PATH, cli|"path/to/license/file"}
8181
{parse_transform, lager_transform},
8282
warnings_as_errors,
8383
debug_info,
@@ -114,7 +114,7 @@
114114
{profiles, [
115115
{test, [
116116
{deps, [
117-
{meck,
117+
{meck,
118118
{git,"https://github.com/eproxus/meck", {ref,"e48641a20a605174e640ac91a528d443be11c9b9"}}},
119119
{automeck,
120120
{git, "https://github.com/chef/automeck", {branch, "otp_24"}}},

src/oc_erchef/rebar.config.script

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
%% -*- mode: erlang -*-
2+
%% ex: ts=4 sw=4 ft=erlang
3+
4+
%% Include erl_opts {d, OC_LICENSE_PATH, X} where X is determined based on the OC_LICENSE_PATH environment variable for compile-time resolution of the path.
5+
%% Special value 'cli' means to use the automate CLI
6+
%% to obtain license information intead. This is the default.
7+
LicensePath = os:getenv("OC_LICENSE_PATH", cli),
8+
ErlOpts = proplists:get_value(erl_opts, CONFIG, []),
9+
UpdatedErlOpts = lists:keystore(d, 99, ErlOpts,
10+
{d, 'OC_LICENSE_PATH', LicensePath}),
11+
Config1 = lists:keydelete(erl_opts, 1, CONFIG),
12+
[{erl_opts, UpdatedErlOpts} | Config1].

src/oc_erchef/rebar.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
1},
5353
{<<"erlcloud">>,
5454
{git,"https://github.com/chef/erlcloud",
55-
{branch,"CHEF-11677/CHEF-12498/lbaker"}},
55+
{ref,"398d67ecfe6d398a444d42ab9e94e8b1df0171a5"}},
5656
0},
5757
{<<"erlware_commons">>,
5858
{git,"https://github.com/chef/erlware_commons",
@@ -100,7 +100,7 @@
100100
1},
101101
{<<"mini_s3">>,
102102
{git,"https://github.com/chef/mini_s3",
103-
{branch,"CHEF-11677/CHEF-12498/lbaker"}},
103+
{ref,"aa206d4d5a8380aff68629b46c15b87931e5c801"}},
104104
0},
105105
{<<"mixer">>,
106106
{git,"https://github.com/inaka/mixer",

0 commit comments

Comments
 (0)