fix(workspace): stabilise the development stack (#2024)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-26 18:52:53 +02:00
1 parent cf1a7d7a8a
commit 5d2e5932a4
55 files changed
+1848 -3396

No files matched your search

@@ -59,7 +59,7 @@ env_services_config() ->
-spec env_gateway_config() -> map().
env_gateway_config() ->
maps:merge(env_gateway_base_config(), env_gateway_hotpatch_config()).
env_gateway_base_config().
-spec env_gateway_base_config() -> map().
env_gateway_base_config() ->
@@ -107,36 +107,6 @@ env_gateway_base_config() ->
)
}.
-spec env_gateway_hotpatch_config() -> map().
env_gateway_hotpatch_config() ->
#{
<<"hotpatch_enabled">> => env_bool("FLUXER_GATEWAY_HOTPATCH_ENABLED", false),
<<"hotpatch_cassandra_hosts">> => env_optional_binary(
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_HOSTS"
),
<<"hotpatch_cassandra_port">> => env_int(
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PORT", 9042
),
<<"hotpatch_cassandra_keyspace">> => env_binary(
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_KEYSPACE", <<"fluxer">>
),
<<"hotpatch_cassandra_username">> => env_optional_binary(
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_USERNAME"
),
<<"hotpatch_cassandra_password">> => env_optional_binary(
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PASSWORD"
),
<<"hotpatch_public_keys">> => env_optional_binary(
"FLUXER_GATEWAY_HOTPATCH_PUBLIC_KEYS"
),
<<"hotpatch_poll_interval_ms">> => env_int(
"FLUXER_GATEWAY_HOTPATCH_POLL_INTERVAL_MS", 5000
),
<<"hotpatch_startup_sync_timeout_ms">> => env_int(
"FLUXER_GATEWAY_HOTPATCH_STARTUP_SYNC_TIMEOUT_MS", 30000
)
}.
-spec env_nats_config() -> map().
env_nats_config() ->
#{
@@ -295,20 +265,6 @@ build_http_config(Service) ->
),
gateway_http_recovery_timeout_ms =>
get_int(Service, <<"gateway_http_recovery_timeout_ms">>, 15000),
hotpatch_enabled => get_bool(Service, <<"hotpatch_enabled">>, false),
hotpatch_cassandra_hosts =>
optional_string(get_optional_binary(Service, <<"hotpatch_cassandra_hosts">>)),
hotpatch_cassandra_port => get_int(Service, <<"hotpatch_cassandra_port">>, 9042),
hotpatch_cassandra_keyspace =>
get_binary(Service, <<"hotpatch_cassandra_keyspace">>, <<"fluxer">>),
hotpatch_cassandra_username =>
get_optional_binary(Service, <<"hotpatch_cassandra_username">>),
hotpatch_cassandra_password =>
get_optional_binary(Service, <<"hotpatch_cassandra_password">>),
hotpatch_public_keys => get_optional_binary(Service, <<"hotpatch_public_keys">>),
hotpatch_poll_interval_ms => get_int(Service, <<"hotpatch_poll_interval_ms">>, 5000),
hotpatch_startup_sync_timeout_ms =>
get_int(Service, <<"hotpatch_startup_sync_timeout_ms">>, 30000),
gateway_http_cleanup_interval_ms =>
get_int(Service, <<"gateway_http_cleanup_interval_ms">>, 30000),
gateway_http_cleanup_max_age_ms =>
@@ -38,8 +38,7 @@ common_children() ->
child_spec(gateway_nats_pool, gateway_nats_pool),
child_spec(gateway_event_pause, gateway_event_pause),
child_spec(gateway_concurrency, gateway_concurrency),
child_spec(gateway_rollout_config, gateway_rollout_config),
child_spec(gateway_hotpatch_reconciler, gateway_hotpatch_reconciler)
child_spec(gateway_rollout_config, gateway_rollout_config)
] ++ cluster_children() ++
[
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
@@ -1,292 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_bundle).
-typing([eqwalizer]).
-export([
compress_term/1,
decompress_term/1,
bundle_hash/1,
signing_payload/1,
sign/2,
verify_signature/4,
parse_public_keys/1,
decode_signed_event/2
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-define(DOMAIN, <<"fluxer-gateway-hotpatch-v1">>).
-define(ZSTD_LEVEL, 3).
-spec compress_term(term()) -> {ok, binary()} | {error, term()}.
compress_term(Term) ->
compress_binary(term_to_binary(Term, [deterministic])).
-spec decompress_term(binary()) -> {ok, term()} | {error, term()}.
decompress_term(Compressed) when is_binary(Compressed) ->
case decompress_binary(Compressed) of
{ok, Binary} -> decode_term(Binary);
{error, Reason} -> {error, Reason}
end.
-spec decode_term(binary()) -> {ok, term()} | {error, term()}.
decode_term(Binary) ->
try
{ok, binary_to_term(Binary, [safe])}
catch
Class:Reason -> {error, {invalid_term, Class, Reason}}
end.
-spec bundle_hash(binary()) -> binary().
bundle_hash(CompressedBundle) when is_binary(CompressedBundle) ->
crypto:hash(sha256, CompressedBundle).
-spec signing_payload(binary()) -> binary().
signing_payload(CompressedBundle) when is_binary(CompressedBundle) ->
<<?DOMAIN/binary, 0, CompressedBundle/binary>>.
-spec sign(binary(), binary()) -> {ok, binary()} | {error, term()}.
sign(CompressedBundle, PrivateKey) when is_binary(CompressedBundle), is_binary(PrivateKey) ->
try
{ok, crypto:sign(eddsa, none, signing_payload(CompressedBundle), [PrivateKey, ed25519])}
catch
Class:Reason -> {error, {sign_failed, Class, Reason}}
end.
-spec verify_signature(binary(), binary(), binary(), #{binary() => binary()}) ->
ok | {error, term()}.
verify_signature(CompressedBundle, SignerKeyId, Signature, PublicKeys) when
is_binary(CompressedBundle),
is_binary(SignerKeyId),
is_binary(Signature),
is_map(PublicKeys)
->
case maps:get(SignerKeyId, PublicKeys, undefined) of
undefined ->
{error, {unknown_signer, SignerKeyId}};
PublicKey ->
verify_with_key(CompressedBundle, Signature, PublicKey)
end.
-spec parse_public_keys(term()) ->
{ok, #{binary() => binary()}} | {error, term()}.
parse_public_keys(undefined) ->
{ok, #{}};
parse_public_keys(Value) when is_list(Value) ->
parse_public_keys(type_conv:ensure_binary(Value));
parse_public_keys(Value) when is_binary(Value) ->
Tokens = [
string:trim(Token)
|| Token <- binary:split(Value, [<<",">>, <<"\n">>, <<";">>], [global]),
string:trim(Token) =/= <<>>
],
parse_public_key_tokens(Tokens, #{});
parse_public_keys(Value) ->
{error, {invalid_public_keys_config, Value}}.
-spec decode_signed_event(map(), #{binary() => binary()}) -> {ok, map()} | {error, term()}.
decode_signed_event(Event, PublicKeys) when is_map(Event), is_map(PublicKeys) ->
Bundle = event_value(bundle, Event),
SignerKeyId = event_value(signer_key_id, Event),
Signature = event_value(signature, Event),
BundleHash = event_value(bundle_sha256, Event),
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys).
-spec decode_event_payload(term(), term(), term(), term(), #{binary() => binary()}) ->
{ok, map()} | {error, term()}.
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys) when
is_binary(Bundle), is_binary(SignerKeyId), is_binary(Signature), is_binary(BundleHash)
->
case validate_event_hash(Bundle, BundleHash) of
ok -> decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys);
{error, Reason} -> {error, Reason}
end;
decode_event_payload(_Bundle, _SignerKeyId, _Signature, _BundleHash, _PublicKeys) ->
{error, invalid_event_payload}.
-spec decode_verified_event(binary(), binary(), binary(), #{binary() => binary()}) ->
{ok, map()} | {error, term()}.
decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys) ->
case verify_signature(Bundle, SignerKeyId, Signature, PublicKeys) of
ok -> decompress_bundle_map(Bundle);
{error, Reason} -> {error, Reason}
end.
-spec decompress_bundle_map(binary()) -> {ok, map()} | {error, term()}.
decompress_bundle_map(Bundle) ->
case decompress_term(Bundle) of
{ok, Map} when is_map(Map) -> {ok, Map};
{ok, Other} -> {error, {invalid_bundle_term, Other}};
{error, Reason} -> {error, Reason}
end.
-spec compress_binary(binary()) -> {ok, binary()} | {error, term()}.
compress_binary(Binary) ->
try erlang:apply(ezstd, compress, [Binary, ?ZSTD_LEVEL]) of
Compressed when is_binary(Compressed) -> {ok, Compressed};
{error, Reason} -> {error, {compress_failed, Reason}};
Other -> {error, {compress_failed, Other}}
catch
Class:Reason -> {error, {compress_failed, Class, Reason}}
end.
-spec decompress_binary(binary()) -> {ok, binary()} | {error, term()}.
decompress_binary(Binary) ->
try erlang:apply(ezstd, decompress, [Binary]) of
Decompressed when is_binary(Decompressed) -> {ok, Decompressed};
Decompressed when is_list(Decompressed) -> {ok, iolist_to_binary(Decompressed)};
{error, Reason} -> {error, {decompress_failed, Reason}};
Other -> {error, {decompress_failed, Other}}
catch
Class:Reason -> {error, {decompress_failed, Class, Reason}}
end.
-spec verify_with_key(binary(), binary(), binary()) -> ok | {error, term()}.
verify_with_key(CompressedBundle, Signature, PublicKey) when byte_size(PublicKey) =:= 32 ->
verify_with_valid_key(CompressedBundle, Signature, PublicKey);
verify_with_key(_CompressedBundle, _Signature, PublicKey) ->
{error, {invalid_public_key_size, byte_size(PublicKey)}}.
-spec verify_with_valid_key(binary(), binary(), binary()) -> ok | {error, term()}.
verify_with_valid_key(CompressedBundle, Signature, PublicKey) ->
try
crypto:verify(eddsa, none, signing_payload(CompressedBundle), Signature, [
PublicKey, ed25519
])
of
true -> ok;
false -> {error, invalid_signature}
catch
Class:Reason -> {error, {verify_failed, Class, Reason}}
end.
-spec parse_public_key_tokens([binary()], #{binary() => binary()}) ->
{ok, #{binary() => binary()}} | {error, term()}.
parse_public_key_tokens([], Acc) ->
{ok, Acc};
parse_public_key_tokens([Token | Rest], Acc) ->
case parse_public_key_token(Token) of
{ok, KeyId, PublicKey} -> parse_public_key_tokens(Rest, Acc#{KeyId => PublicKey});
{error, Reason} -> {error, Reason}
end.
-spec parse_public_key_token(binary()) -> {ok, binary(), binary()} | {error, term()}.
parse_public_key_token(Token) ->
case split_key_token(Token) of
{ok, KeyId, Encoded} -> parse_public_key_material(KeyId, Encoded);
error -> {error, {invalid_public_key_token, Token}}
end.
-spec parse_public_key_material(binary(), binary()) ->
{ok, binary(), binary()} | {error, term()}.
parse_public_key_material(KeyId, Encoded) ->
case decode_key_material(Encoded) of
{ok, PublicKey} when byte_size(PublicKey) =:= 32 -> {ok, KeyId, PublicKey};
{ok, PublicKey} -> {error, {invalid_public_key_size, KeyId, byte_size(PublicKey)}};
{error, Reason} -> {error, {invalid_public_key, KeyId, Reason}}
end.
-spec split_key_token(binary()) -> {ok, binary(), binary()} | error.
split_key_token(Token) ->
case binary:split(Token, <<":">>) of
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
_ -> split_key_token_equals(Token)
end.
-spec split_key_token_equals(binary()) -> {ok, binary(), binary()} | error.
split_key_token_equals(Token) ->
case binary:split(Token, <<"=">>) of
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
_ -> error
end.
-spec decode_key_material(binary()) -> {ok, binary()} | {error, term()}.
decode_key_material(Encoded) ->
case try_base64(Encoded) of
{ok, Decoded} -> {ok, Decoded};
{error, _} -> try_base64url(Encoded)
end.
-spec try_base64(binary()) -> {ok, binary()} | {error, term()}.
try_base64(Encoded) ->
try
{ok, base64:decode(Encoded)}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec try_base64url(binary()) -> {ok, binary()} | {error, term()}.
try_base64url(Encoded) ->
try
{ok, base64url:decode(Encoded)}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec validate_event_hash(binary(), binary()) -> ok | {error, term()}.
validate_event_hash(Bundle, BundleHash) ->
case bundle_hash(Bundle) of
BundleHash -> ok;
Other -> {error, {bundle_hash_mismatch, Other, BundleHash}}
end.
-spec event_value(atom(), map()) -> term().
event_value(Key, Event) ->
maps:get(Key, Event, maps:get(atom_to_binary(Key, utf8), Event, undefined)).
-ifdef(TEST).
compress_decompress_roundtrip_test() ->
Term = #{
version => 1,
build_sha => <<"abc123">>,
modules => [#{module => <<"session_lifecycle">>, expected_current_md5 => <<0:128>>}]
},
{ok, Compressed} = compress_term(Term),
?assert(is_binary(Compressed)),
?assertEqual({ok, Term}, decompress_term(Compressed)).
sign_and_verify_roundtrip_test() ->
{PublicKey, PrivateKey} = ed25519_keypair(),
{ok, Compressed} = compress_term(#{version => 1}),
{ok, Signature} = sign(Compressed, PrivateKey),
Keys = #{<<"ops">> => PublicKey},
?assertEqual(ok, verify_signature(Compressed, <<"ops">>, Signature, Keys)),
?assertEqual(
{error, invalid_signature},
verify_signature(<<Compressed/binary, 0>>, <<"ops">>, Signature, Keys)
).
decode_signed_event_rejects_hash_mismatch_test() ->
{PublicKey, PrivateKey} = ed25519_keypair(),
{ok, Compressed} = compress_term(#{version => 1}),
{ok, Signature} = sign(Compressed, PrivateKey),
Event = #{
signer_key_id => <<"ops">>,
signature => Signature,
bundle_sha256 => <<0:256>>,
bundle => Compressed
},
?assertMatch(
{error, {bundle_hash_mismatch, _, _}},
decode_signed_event(Event, #{<<"ops">> => PublicKey})
).
parse_public_keys_test() ->
PublicKey = <<1:256>>,
Encoded = base64:encode(PublicKey),
?assertEqual(
{ok, #{<<"ops">> => PublicKey}}, parse_public_keys(<<"ops:", Encoded/binary>>)
).
ed25519_keypair() ->
{PublicKey, PrivateKey} = crypto:generate_key(eddsa, ed25519),
{require_binary(PublicKey), require_binary(PrivateKey)}.
require_binary(Value) when is_binary(Value) ->
Value.
-endif.
@@ -1,459 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_cli).
-typing([eqwalizer]).
-export([main/1, build_bundle/2, sign_bundle/3]).
-spec main([string()]) -> no_return().
main(Args) ->
halt(run_main(Args)).
-spec run_main([string()]) -> non_neg_integer().
run_main(["bundle", BuildSha | ModuleOrder]) when ModuleOrder =/= [] ->
write_json_result(build_bundle(type_conv:ensure_binary(BuildSha), ModuleOrder));
run_main(["sign", SignerKeyId, PrivateKeyPath, BundlePath]) ->
write_json_result(sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath));
run_main(["append", BuildSha, CreatedBy, EventPath]) ->
Result = append_event(
type_conv:ensure_binary(BuildSha), type_conv:ensure_binary(CreatedBy), EventPath
),
write_append_result(Result);
run_main(_Args) ->
write_usage(),
64.
-spec build_bundle(binary(), [string()]) -> {ok, map()} | {error, term()}.
build_bundle(BuildSha, ModuleSpecs) when is_binary(BuildSha), is_list(ModuleSpecs) ->
build_bundle_modules(ModuleSpecs, fun(Modules) ->
{ok, #{<<"version">> => 1, <<"build_sha">> => BuildSha, <<"modules">> => Modules}}
end).
-spec sign_bundle(binary(), file:filename(), binary()) -> {ok, map()} | {error, term()}.
sign_bundle(SignerKeyId, PrivateKeyPath, BundleJson) when
is_binary(SignerKeyId), is_binary(BundleJson)
->
case decode_json(BundleJson) of
{ok, Bundle} -> sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle);
{error, Reason} -> {error, Reason}
end.
-spec sign_decoded_bundle_with_key(binary(), file:filename(), term()) ->
{ok, map()} | {error, term()}.
sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle) ->
case read_private_key(PrivateKeyPath) of
{ok, PrivateKey} -> sign_decoded_bundle(SignerKeyId, Bundle, PrivateKey);
{error, Reason} -> {error, Reason}
end.
-spec sign_bundle_file(string(), file:filename(), file:filename()) ->
{ok, map()} | {error, term()}.
sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath) ->
case file:read_file(BundlePath) of
{ok, BundleJson} ->
sign_bundle(type_conv:ensure_binary(SignerKeyId), PrivateKeyPath, BundleJson);
{error, Reason} ->
{error, {read_bundle_failed, Reason}}
end.
-spec sign_decoded_bundle(binary(), term(), binary()) -> {ok, map()} | {error, term()}.
sign_decoded_bundle(SignerKeyId, BundleJson, PrivateKey) ->
case json_to_bundle(BundleJson) of
{ok, Bundle} -> sign_bundle_term(SignerKeyId, Bundle, PrivateKey);
{error, Reason} -> {error, Reason}
end.
-spec sign_bundle_term(binary(), map(), binary()) -> {ok, map()} | {error, term()}.
sign_bundle_term(SignerKeyId, Bundle, PrivateKey) ->
case gateway_hotpatch_bundle:compress_term(Bundle) of
{ok, Compressed} -> sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey);
{error, Reason} -> {error, Reason}
end.
-spec sign_compressed_bundle(binary(), binary(), binary()) -> {ok, map()} | {error, term()}.
sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey) ->
case gateway_hotpatch_bundle:sign(Compressed, PrivateKey) of
{ok, Signature} -> {ok, signed_event(SignerKeyId, Compressed, Signature)};
{error, Reason} -> {error, Reason}
end.
-spec signed_event(binary(), binary(), binary()) -> map().
signed_event(SignerKeyId, Compressed, Signature) ->
#{
<<"schema_version">> => 1,
<<"kind">> => <<"beam_bundle">>,
<<"created_by">> => gateway_hotpatch_runtime:node_name(),
<<"signer_key_id">> => SignerKeyId,
<<"bundle_sha256">> => encode_bytes(gateway_hotpatch_bundle:bundle_hash(Compressed)),
<<"signature">> => encode_bytes(Signature),
<<"bundle">> => encode_bytes(Compressed)
}.
-spec append_event(binary(), binary(), file:filename()) -> {ok, binary()} | {error, term()}.
append_event(BuildSha, CreatedBy, EventPath) ->
case file:read_file(EventPath) of
{ok, EventJson} -> append_event_json(BuildSha, CreatedBy, EventJson);
{error, Reason} -> {error, {read_event_failed, Reason}}
end.
-spec append_event_json(binary(), binary(), binary()) -> {ok, binary()} | {error, term()}.
append_event_json(BuildSha, CreatedBy, EventJson) ->
case decode_json(EventJson) of
{ok, EventJsonTerm} -> append_event_term(BuildSha, CreatedBy, EventJsonTerm);
{error, Reason} -> {error, Reason}
end.
-spec append_event_term(binary(), binary(), term()) -> {ok, binary()} | {error, term()}.
append_event_term(BuildSha, CreatedBy, EventJsonTerm) ->
case json_to_event(EventJsonTerm) of
{ok, Event} -> append_event_row(BuildSha, CreatedBy, Event);
{error, Reason} -> {error, Reason}
end.
-spec build_bundle_modules([string()], fun(([map()]) -> {ok, map()})) ->
{ok, map()} | {error, term()}.
build_bundle_modules(ModuleSpecs, Fun) ->
case build_bundle_module_list(ModuleSpecs, []) of
{ok, Modules} -> Fun(Modules);
{error, Reason} -> {error, Reason}
end.
-spec build_bundle_module_list([string()], [map()]) -> {ok, [map()]} | {error, term()}.
build_bundle_module_list([], Acc) ->
{ok, lists:reverse(Acc)};
build_bundle_module_list([ModuleSpec | Rest], Acc) ->
case bundle_module(ModuleSpec) of
{ok, Module} -> build_bundle_module_list(Rest, [Module | Acc]);
{error, Reason} -> {error, Reason}
end.
-spec bundle_module(string()) -> {ok, map()} | {error, term()}.
bundle_module(ModuleSpec) ->
case split_module_spec(ModuleSpec) of
{ok, ModuleName, BeamPath0} -> bundle_named_module(ModuleName, BeamPath0);
{error, Reason} -> {error, Reason}
end.
-spec bundle_named_module(string(), file:filename() | undefined) ->
{ok, map()} | {error, term()}.
bundle_named_module(ModuleName, BeamPath0) ->
case existing_module(ModuleName) of
{ok, Module} -> bundle_existing_module(Module, BeamPath0);
{error, Reason} -> {error, Reason}
end.
-spec bundle_existing_module(atom(), file:filename() | undefined) ->
{ok, map()} | {error, term()}.
bundle_existing_module(Module, BeamPath0) ->
case collect_bundle_parts(Module, BeamPath0) of
{ok, CurrentMd5, TargetMd5, CompressedBeam} ->
{ok, build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam)};
{error, Reason} ->
{error, Reason}
end.
-spec collect_bundle_parts(atom(), file:filename() | undefined) ->
{ok, binary(), binary(), binary()} | {error, term()}.
collect_bundle_parts(Module, BeamPath0) ->
case gateway_hotpatch_loader:current_md5(Module) of
{ok, CurrentMd5} -> collect_bundle_beam(Module, BeamPath0, CurrentMd5);
{error, Reason} -> {error, {current_md5_failed, Module, Reason}}
end.
-spec collect_bundle_beam(atom(), file:filename() | undefined, binary()) ->
{ok, binary(), binary(), binary()} | {error, term()}.
collect_bundle_beam(Module, BeamPath0, CurrentMd5) ->
case resolve_beam_path(Module, BeamPath0) of
{ok, BeamPath} -> read_bundle_beam(Module, BeamPath, CurrentMd5);
{error, Reason} -> {error, Reason}
end.
-spec read_bundle_beam(atom(), file:filename(), binary()) ->
{ok, binary(), binary(), binary()} | {error, term()}.
read_bundle_beam(Module, BeamPath, CurrentMd5) ->
case file:read_file(BeamPath) of
{ok, Beam} -> hash_bundle_beam(Module, CurrentMd5, Beam);
{error, Reason} -> {error, {read_beam_failed, Module, BeamPath, Reason}}
end.
-spec hash_bundle_beam(atom(), binary(), binary()) ->
{ok, binary(), binary(), binary()} | {error, term()}.
hash_bundle_beam(Module, CurrentMd5, Beam) ->
case gateway_hotpatch_loader:beam_md5(Beam) of
{ok, TargetMd5} -> compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam);
{error, Reason} -> {error, {target_md5_failed, Module, Reason}}
end.
-spec compress_bundle_beam(atom(), binary(), binary(), binary()) ->
{ok, binary(), binary(), binary()} | {error, term()}.
compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam) ->
case compress_beam(Beam) of
{ok, CompressedBeam} -> {ok, CurrentMd5, TargetMd5, CompressedBeam};
{error, Reason} -> {error, {beam_compress_failed, Module, Reason}}
end.
-spec build_module_entry(atom(), binary(), binary(), binary()) -> map().
build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam) ->
#{
<<"module">> => atom_to_binary(Module, utf8),
<<"expected_current_md5">> => encode_bytes(CurrentMd5),
<<"target_md5">> => encode_bytes(TargetMd5),
<<"beam_zstd">> => encode_bytes(CompressedBeam)
}.
-spec append_event_row(binary(), binary(), map()) -> {ok, binary()} | {error, term()}.
append_event_row(BuildSha, CreatedBy, Event) ->
_ = fluxer_gateway_env:load(),
case gateway_hotpatch_store:connect() of
ok ->
gateway_hotpatch_store:append_event(
BuildSha,
CreatedBy,
maps:get(signer_key_id, Event),
maps:get(signature, Event),
maps:get(bundle_sha256, Event),
maps:get(bundle, Event)
);
{error, Reason} ->
{error, Reason}
end.
-spec split_module_spec(string()) ->
{ok, string(), file:filename() | undefined} | {error, term()}.
split_module_spec(ModuleSpec) ->
case string:split(ModuleSpec, "=", leading) of
[ModuleName, BeamPath] when ModuleName =/= "", BeamPath =/= "" ->
{ok, ModuleName, BeamPath};
[ModuleName] when ModuleName =/= "" ->
{ok, ModuleName, undefined};
_ ->
{error, {invalid_module_spec, ModuleSpec}}
end.
-spec existing_module(string()) -> {ok, atom()} | {error, term()}.
existing_module(ModuleName) ->
try
{ok, list_to_existing_atom(ModuleName)}
catch
error:badarg -> {error, {unknown_module, ModuleName}}
end.
-spec resolve_beam_path(atom(), file:filename() | undefined) ->
{ok, file:filename()} | {error, term()}.
resolve_beam_path(Module, undefined) ->
case code:which(Module) of
File when is_list(File) -> {ok, File};
Other -> {error, {module_beam_not_found, Module, Other}}
end;
resolve_beam_path(_Module, BeamPath) ->
{ok, BeamPath}.
-spec compress_beam(binary()) -> {ok, binary()} | {error, term()}.
compress_beam(Beam) ->
try erlang:apply(ezstd, compress, [Beam, 3]) of
Compressed when is_binary(Compressed) -> {ok, Compressed};
{error, Reason} -> {error, Reason};
Other -> {error, Other}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec decode_json(binary()) -> {ok, term()} | {error, term()}.
decode_json(Json) ->
try
{ok, json:decode(Json)}
catch
Class:Reason -> {error, {decode_json_failed, Class, Reason}}
end.
-spec read_private_key(file:filename()) -> {ok, binary()} | {error, term()}.
read_private_key(Path) ->
case file:read_file(Path) of
{ok, PrivateKey} when byte_size(PrivateKey) =:= 32 ->
{ok, PrivateKey};
{ok, PrivateKey} ->
{error, {invalid_private_key_size, byte_size(PrivateKey)}};
{error, Reason} ->
{error, {read_private_key_failed, Reason}}
end.
-spec json_to_bundle(term()) -> {ok, map()} | {error, term()}.
json_to_bundle(#{
<<"version">> := Version, <<"build_sha">> := BuildSha, <<"modules">> := Modules
}) when is_list(Modules) ->
case json_to_module_entries(Modules, []) of
{ok, Entries} ->
{ok, #{
<<"version">> => Version,
<<"build_sha">> => BuildSha,
<<"modules">> => Entries
}};
{error, Reason} ->
{error, Reason}
end;
json_to_bundle(Other) ->
{error, {invalid_bundle_json, Other}}.
-spec json_to_module_entries([term()], [map()]) -> {ok, [map()]} | {error, term()}.
json_to_module_entries([], Acc) ->
{ok, lists:reverse(Acc)};
json_to_module_entries([Module | Rest], Acc) ->
case json_to_module_entry(Module) of
{ok, Entry} -> json_to_module_entries(Rest, [Entry | Acc]);
{error, Reason} -> {error, Reason}
end.
-spec json_to_module_entry(term()) -> {ok, map()} | {error, term()}.
json_to_module_entry(#{
<<"module">> := Module,
<<"expected_current_md5">> := ExpectedMd5,
<<"target_md5">> := TargetMd5,
<<"beam_zstd">> := BeamZstd
}) ->
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd);
json_to_module_entry(Other) ->
{error, {invalid_module_entry_json, Other}}.
-spec decode_module_entry(term(), term(), term(), term()) ->
{ok, map()} | {error, term()}.
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) when
is_binary(Module), is_binary(ExpectedMd5), is_binary(TargetMd5), is_binary(BeamZstd)
->
case decode_bytes(ExpectedMd5) of
{ok, ExpectedMd5Bytes} ->
decode_module_entry_target(Module, ExpectedMd5Bytes, TargetMd5, BeamZstd);
{error, Reason} ->
{error, Reason}
end;
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
{error, {invalid_module_entry_json, {Module, ExpectedMd5, TargetMd5, BeamZstd}}}.
-spec decode_module_entry_target(binary(), binary(), binary(), binary()) ->
{ok, map()} | {error, term()}.
decode_module_entry_target(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
case decode_bytes(TargetMd5) of
{ok, TargetMd5Bytes} ->
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5Bytes, BeamZstd);
{error, Reason} ->
{error, Reason}
end.
-spec decode_module_entry_beam(binary(), binary(), binary(), binary()) ->
{ok, map()} | {error, term()}.
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
case decode_bytes(BeamZstd) of
{ok, BeamZstdBytes} ->
{ok, #{
<<"module">> => Module,
<<"expected_current_md5">> => ExpectedMd5,
<<"target_md5">> => TargetMd5,
<<"beam_zstd">> => BeamZstdBytes
}};
{error, Reason} ->
{error, Reason}
end.
-spec json_to_event(term()) -> {ok, map()} | {error, term()}.
json_to_event(#{
<<"signer_key_id">> := SignerKeyId,
<<"bundle_sha256">> := BundleSha256,
<<"signature">> := Signature,
<<"bundle">> := Bundle
}) ->
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle);
json_to_event(Other) ->
{error, {invalid_event_json, Other}}.
-spec decode_event_bytes(term(), term(), term(), term()) ->
{ok, map()} | {error, term()}.
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) when
is_binary(SignerKeyId), is_binary(BundleSha256), is_binary(Signature), is_binary(Bundle)
->
case decode_bytes(BundleSha256) of
{ok, BundleSha256Bytes} ->
decode_event_signature(SignerKeyId, BundleSha256Bytes, Signature, Bundle);
{error, Reason} ->
{error, Reason}
end;
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) ->
{error, {invalid_event_json, {SignerKeyId, BundleSha256, Signature, Bundle}}}.
-spec decode_event_signature(binary(), binary(), binary(), binary()) ->
{ok, map()} | {error, term()}.
decode_event_signature(SignerKeyId, BundleSha256, Signature, Bundle) ->
case decode_bytes(Signature) of
{ok, SignatureBytes} ->
decode_event_bundle(SignerKeyId, BundleSha256, SignatureBytes, Bundle);
{error, Reason} ->
{error, Reason}
end.
-spec decode_event_bundle(binary(), binary(), binary(), binary()) ->
{ok, map()} | {error, term()}.
decode_event_bundle(SignerKeyId, BundleSha256, Signature, Bundle) ->
case decode_bytes(Bundle) of
{ok, BundleBytes} ->
{ok, #{
signer_key_id => SignerKeyId,
bundle_sha256 => BundleSha256,
signature => Signature,
bundle => BundleBytes
}};
{error, Reason} ->
{error, Reason}
end.
-spec encode_bytes(binary()) -> binary().
encode_bytes(Binary) ->
base64:encode(Binary).
-spec decode_bytes(binary()) -> {ok, binary()} | {error, term()}.
decode_bytes(Encoded) when is_binary(Encoded) ->
try
{ok, base64:decode(Encoded)}
catch
Class:Reason -> {error, {invalid_base64_bytes, Encoded, Class, Reason}}
end.
-spec write_json_result({ok, map()} | {error, term()}) -> non_neg_integer().
write_json_result({ok, Term}) ->
write_stdout("~ts~n", [json:encode(Term)]),
0;
write_json_result({error, Reason}) ->
write_error(Reason).
-spec write_append_result({ok, binary()} | {error, term()}) -> non_neg_integer().
write_append_result({ok, EventId}) ->
write_stdout("appended hotpatch event ~ts~n", [gateway_hotpatch_loader:hex(EventId)]),
0;
write_append_result({error, Reason}) ->
write_error(Reason).
-spec write_error(term()) -> non_neg_integer().
write_error(Reason) ->
write_stderr("gateway hotpatch failed: ~0tp~n", [Reason]),
1.
-spec write_usage() -> ok.
write_usage() ->
write_stderr(
"usage: gateway_hotpatch_cli bundle BUILD_SHA module_a=/path/to/module_a.beam ...~n"
" gateway_hotpatch_cli sign SIGNER_KEY_ID PRIVATE_KEY_RAW_FILE bundle.json~n"
" gateway_hotpatch_cli append BUILD_SHA CREATED_BY signed-event.json~n",
[]
).
-spec write_stdout(io:format(), [term()]) -> ok.
write_stdout(Format, Args) ->
write_stream(standard_io, Format, Args).
-spec write_stderr(io:format(), [term()]) -> ok.
write_stderr(Format, Args) ->
write_stream(standard_error, Format, Args).
-spec write_stream(file:io_device() | standard_io | standard_error, io:format(), [term()]) ->
ok.
write_stream(Device, Format, Args) ->
Output = iolist_to_binary(io_lib:format(Format, Args)),
_ = file:write(Device, Output),
ok.
@@ -1,433 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_loader).
-typing([eqwalizer]).
-export([
apply_bundle/1,
current_md5/1,
beam_module/1,
beam_md5/1,
hex/1
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type module_entry() :: map().
-type apply_result() :: {ok, applied | skipped, atom(), binary()} | {error, term()}.
-spec apply_bundle(map()) -> {ok, map()} | {error, term()}.
apply_bundle(Bundle) when is_map(Bundle) ->
case
{
entry_value(version, Bundle),
entry_value(build_sha, Bundle),
entry_value(modules, Bundle)
}
of
{1, BundleBuildSha, Modules} when is_list(Modules) ->
apply_versioned_bundle(BundleBuildSha, Modules);
_ ->
{error, invalid_bundle}
end;
apply_bundle(_Bundle) ->
{error, invalid_bundle}.
-spec apply_versioned_bundle(term(), [module_entry()]) -> {ok, map()} | {error, term()}.
apply_versioned_bundle(BundleBuildSha, Modules) ->
CurrentBuildSha = gateway_hotpatch_runtime:build_sha(),
case normalize_binary(BundleBuildSha) of
CurrentBuildSha -> apply_modules(Modules, #{applied => [], skipped => []});
Other -> {error, {build_sha_mismatch, Other, CurrentBuildSha}}
end.
-spec current_md5(atom()) -> {ok, binary()} | {error, term()}.
current_md5(Module) when is_atom(Module) ->
case hotpatch_loaded_md5(Module) of
{ok, Md5} -> {ok, Md5};
error -> current_md5_result(Module, current_md5_from_loaded_or_file(Module))
end.
-spec current_md5_result(atom(), {ok, binary()} | {error, term()}) ->
{ok, binary()} | {error, term()}.
current_md5_result(_Module, {ok, Md5}) ->
{ok, Md5};
current_md5_result(Module, {error, Reason}) ->
fallback_current_md5(Module, Reason).
-spec fallback_current_md5(atom(), term()) -> {ok, binary()} | {error, term()}.
fallback_current_md5(Module, Reason) ->
case hotpatch_loaded_md5(Module) of
{ok, Md5} -> {ok, Md5};
error -> {error, Reason}
end.
-spec current_md5_from_loaded_or_file(atom()) -> {ok, binary()} | {error, term()}.
current_md5_from_loaded_or_file(Module) ->
case code:get_object_code(Module) of
{Module, Beam, _File} when is_binary(Beam) -> beam_md5(Beam);
error -> current_md5_from_file(Module)
end.
-spec beam_module(binary()) -> {ok, atom()} | {error, term()}.
beam_module(Beam) when is_binary(Beam) ->
case beam_lib:info(Beam) of
Info when is_list(Info) -> beam_module_from_info(Info);
Other -> {error, {beam_info_failed, Other}}
end.
-spec beam_module_from_info(list()) -> {ok, atom()} | {error, term()}.
beam_module_from_info(Info) ->
case lists:keyfind(module, 1, Info) of
{module, Module} when is_atom(Module) -> {ok, Module};
_ -> {error, module_not_found}
end.
-spec beam_md5(binary()) -> {ok, binary()} | {error, term()}.
beam_md5(Beam) when is_binary(Beam) ->
case beam_lib:md5(Beam) of
{ok, {_Module, Md5}} when is_binary(Md5) -> {ok, Md5};
Error -> {error, {beam_md5_failed, Error}}
end.
-spec hex(binary()) -> binary().
hex(Binary) when is_binary(Binary) ->
iolist_to_binary([[hex_nibble(High), hex_nibble(Low)] || <<High:4, Low:4>> <= Binary]).
-spec apply_modules([module_entry()], map()) -> {ok, map()} | {error, term()}.
apply_modules([], Acc) ->
{ok, Acc#{module_count => length(maps:get(applied, Acc)) + length(maps:get(skipped, Acc))}};
apply_modules([Entry | Rest], Acc) ->
case apply_module(Entry) of
{ok, applied, Module, TargetMd5} ->
apply_modules(
Rest,
Acc#{applied => [{Module, hex(TargetMd5)} | maps:get(applied, Acc)]}
);
{ok, skipped, Module, TargetMd5} ->
apply_modules(
Rest,
Acc#{skipped => [{Module, hex(TargetMd5)} | maps:get(skipped, Acc)]}
);
{error, Reason} ->
{error, Reason}
end.
-spec apply_module(module_entry()) -> apply_result().
apply_module(Entry) ->
with_entry(Entry, fun apply_valid_entry/4).
-spec apply_valid_entry(atom(), binary(), binary(), binary()) -> apply_result().
apply_valid_entry(Module, ExpectedMd5, TargetMd5, Beam) ->
case current_md5(Module) of
{ok, TargetMd5} ->
{ok, skipped, Module, TargetMd5};
{ok, ExpectedMd5} ->
load_module(Module, Beam, TargetMd5);
{ok, CurrentMd5} ->
{error, {md5_mismatch, Module, hex(CurrentMd5), hex(ExpectedMd5)}};
{error, Reason} ->
{error, {current_md5_failed, Module, Reason}}
end.
-spec with_entry(module_entry(), fun((atom(), binary(), binary(), binary()) -> apply_result())) ->
apply_result().
with_entry(Entry, Fun) when is_map(Entry), is_function(Fun, 4) ->
case normalize_module(entry_value(module, Entry)) of
{ok, Module} ->
ExpectedMd5 = entry_value(expected_current_md5, Entry),
TargetMd5 = entry_value(target_md5, Entry),
BeamZstd = entry_value(beam_zstd, Entry),
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun);
{error, Reason} ->
{error, Reason}
end;
with_entry(_Entry, _Fun) ->
{error, invalid_module_entry}.
-spec with_entry_beam(atom(), term(), term(), term(), fun(
(atom(), binary(), binary(), binary()) -> apply_result()
)) ->
apply_result().
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun) when
is_binary(ExpectedMd5),
is_binary(TargetMd5),
byte_size(ExpectedMd5) =:= 16,
byte_size(TargetMd5) =:= 16,
is_binary(BeamZstd)
->
case decompress_beam(BeamZstd) of
{ok, Beam} -> validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun);
{error, Reason} -> {error, {beam_decompress_failed, Module, Reason}}
end;
with_entry_beam(Module, _ExpectedMd5, _TargetMd5, _BeamZstd, _Fun) ->
{error, {invalid_module_entry, Module}}.
-spec validate_beam(atom(), binary(), binary(), binary(), fun(
(atom(), binary(), binary(), binary()) -> apply_result()
)) ->
apply_result().
validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
case beam_module(Beam) of
{ok, Module} -> validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun);
{ok, OtherModule} -> {error, {beam_module_mismatch, Module, OtherModule}};
{error, Reason} -> {error, {beam_module_failed, Module, Reason}}
end.
-spec validate_beam_md5(atom(), binary(), binary(), binary(), fun(
(atom(), binary(), binary(), binary()) -> apply_result()
)) ->
apply_result().
validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
case beam_md5(Beam) of
{ok, TargetMd5} ->
Fun(Module, ExpectedMd5, TargetMd5, Beam);
{ok, OtherMd5} ->
{error, {target_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
{error, Reason} ->
{error, {target_md5_failed, Module, Reason}}
end.
-spec load_module(atom(), binary(), binary()) -> apply_result().
load_module(Module, Beam, TargetMd5) ->
case code:soft_purge(Module) of
true -> load_purged_module(Module, Beam, TargetMd5);
false -> {error, {soft_purge_failed, Module}}
end.
-spec load_purged_module(atom(), binary(), binary()) -> apply_result().
load_purged_module(Module, Beam, TargetMd5) ->
case code:load_binary(Module, atom_to_list(Module) ++ ".beam", Beam) of
{module, Module} ->
put_hotpatch_loaded_md5(Module, TargetMd5),
verify_loaded_module(Module, TargetMd5);
{error, Reason} ->
{error, {load_binary_failed, Module, Reason}}
end.
-spec verify_loaded_module(atom(), binary()) -> apply_result().
verify_loaded_module(Module, TargetMd5) ->
case current_md5(Module) of
{ok, TargetMd5} ->
{ok, applied, Module, TargetMd5};
{ok, OtherMd5} ->
{error, {post_load_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
{error, Reason} ->
{error, {post_load_md5_failed, Module, Reason}}
end.
-spec current_md5_from_file(atom()) -> {ok, binary()} | {error, term()}.
current_md5_from_file(Module) ->
case code:which(Module) of
File when is_list(File) -> current_md5_from_path(Module, File);
preloaded -> {error, preloaded};
non_existing -> {error, not_loaded};
Other -> {error, {not_loadable, Other}}
end.
-spec current_md5_from_path(atom(), file:filename()) -> {ok, binary()} | {error, term()}.
current_md5_from_path(Module, File) ->
case beam_lib:md5(File) of
{ok, {Module, Md5}} when is_binary(Md5) -> {ok, Md5};
Error -> {error, {beam_file_md5_failed, Error}}
end.
-spec hotpatch_loaded_md5(atom()) -> {ok, binary()} | error.
hotpatch_loaded_md5(Module) ->
case persistent_term:get({?MODULE, loaded_md5, Module}, undefined) of
Md5 when is_binary(Md5), byte_size(Md5) =:= 16 -> {ok, Md5};
_ -> error
end.
-spec put_hotpatch_loaded_md5(atom(), binary()) -> ok.
put_hotpatch_loaded_md5(Module, Md5) ->
persistent_term:put({?MODULE, loaded_md5, Module}, Md5).
-spec decompress_beam(binary()) -> {ok, binary()} | {error, term()}.
decompress_beam(Compressed) ->
try erlang:apply(ezstd, decompress, [Compressed]) of
Beam when is_binary(Beam) -> {ok, Beam};
Beam when is_list(Beam) -> {ok, iolist_to_binary(Beam)};
{error, Reason} -> {error, Reason};
Other -> {error, Other}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec normalize_module(term()) -> {ok, atom()} | {error, term()}.
normalize_module(Module) when is_atom(Module) ->
{ok, Module};
normalize_module(Module) when is_binary(Module) ->
try
{ok, binary_to_existing_atom(Module, utf8)}
catch
error:badarg -> {error, {unknown_module, Module}}
end;
normalize_module(Module) when is_list(Module) ->
normalize_module(type_conv:ensure_binary(Module));
normalize_module(Module) ->
{error, {invalid_module, Module}}.
-spec normalize_binary(term()) -> binary().
normalize_binary(Bin) when is_binary(Bin) -> Bin;
normalize_binary(List) when is_list(List) -> type_conv:ensure_binary(List);
normalize_binary(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
normalize_binary(Other) -> term_to_binary(Other).
-spec entry_value(atom(), map()) -> term().
entry_value(Key, Entry) ->
maps:get(Key, Entry, maps:get(atom_to_binary(Key, utf8), Entry, undefined)).
-spec hex_nibble(0..15) -> integer().
hex_nibble(N) when N < 10 -> $0 + N;
hex_nibble(N) -> $a + (N - 10).
-ifdef(TEST).
beam_module_and_md5_test() ->
{module, ?MODULE} = code:ensure_loaded(?MODULE),
{?MODULE, Binary, _File} = code:get_object_code(?MODULE),
?assertEqual({ok, ?MODULE}, beam_module(Binary)),
{ok, Md5} = beam_md5(Binary),
?assertEqual(16, byte_size(Md5)).
apply_bundle_rejects_build_mismatch_test() ->
Bundle = #{version => 1, build_sha => <<"definitely-not-this-build">>, modules => []},
?assertMatch({error, {build_sha_mismatch, _, _}}, apply_bundle(Bundle)).
apply_bundle_accepts_binary_keys_test() ->
Bundle = #{
<<"version">> => 1,
<<"build_sha">> => gateway_hotpatch_runtime:build_sha(),
<<"modules">> => []
},
?assertEqual(
{ok, #{applied => [], skipped => [], module_count => 0}}, apply_bundle(Bundle)
).
apply_bundle_loads_new_beam_test() ->
Module = gateway_hotpatch_loader_test_target,
cleanup_test_module(Module),
try
Beam1 = compile_test_module(Module, 1),
Beam2 = compile_test_module(Module, 2),
{module, Module} = load_test_beam(Module, Beam1),
?assertEqual(1, erlang:apply(Module, version, [])),
{ok, ExpectedMd5} = current_md5(Module),
{Entry, TargetMd5} = beam_entry(Module, ExpectedMd5, Beam2),
Bundle = #{
version => 1,
build_sha => gateway_hotpatch_runtime:build_sha(),
modules => [Entry]
},
?assertEqual(
{ok, #{applied => [{Module, hex(TargetMd5)}], skipped => [], module_count => 1}},
apply_bundle(Bundle)
),
?assertEqual(2, erlang:apply(Module, version, []))
after
cleanup_test_module(Module)
end.
apply_bundle_leaves_prior_module_loaded_when_later_entry_fails_test() ->
ModuleA = gateway_hotpatch_loader_test_partial_a,
ModuleB = gateway_hotpatch_loader_test_partial_b,
cleanup_test_module(ModuleA),
cleanup_test_module(ModuleB),
try
BeamA1 = compile_test_module(ModuleA, 1),
BeamA2 = compile_test_module(ModuleA, 2),
BeamB1 = compile_test_module(ModuleB, 1),
BeamB2 = compile_test_module(ModuleB, 2),
{module, ModuleA} = load_test_beam(ModuleA, BeamA1),
{module, ModuleB} = load_test_beam(ModuleB, BeamB1),
{ok, Md5A1} = current_md5(ModuleA),
WrongExpectedMd5 = <<0:128>>,
{EntryA, _Md5A2} = beam_entry(ModuleA, Md5A1, BeamA2),
{EntryB, _Md5B2} = beam_entry(ModuleB, WrongExpectedMd5, BeamB2),
Bundle = #{
version => 1,
build_sha => gateway_hotpatch_runtime:build_sha(),
modules => [EntryA, EntryB]
},
?assertMatch({error, {md5_mismatch, ModuleB, _, _}}, apply_bundle(Bundle)),
?assertEqual(2, erlang:apply(ModuleA, version, [])),
?assertEqual(1, erlang:apply(ModuleB, version, []))
after
cleanup_test_module(ModuleA),
cleanup_test_module(ModuleB)
end.
hex_test() ->
?assertEqual(<<"0001020f10ff">>, hex(<<0, 1, 2, 15, 16, 255>>)).
compile_test_module(Module, Version) ->
Dir = test_compile_dir(),
Source = filename:join(Dir, atom_to_list(Module) ++ ".erl"),
SourceText = io_lib:format(
"-module(~p).~n-export([version/0]).~nversion() -> ~p.~n",
[Module, Version]
),
ok = file:write_file(Source, SourceText),
case compile:file(Source, [binary, return_errors, return_warnings]) of
{ok, Module, Beam} -> Beam;
{ok, Module, Beam, _Warnings} -> Beam;
Error -> erlang:error({test_module_compile_failed, Module, Error})
end.
test_compile_dir() ->
Root =
case os:getenv("TMPDIR") of
false -> "/tmp";
Value -> Value
end,
Dir = filename:join(Root, "fluxer_hotpatch_loader_tests"),
ok = filelib:ensure_dir(filename:join(Dir, "placeholder")),
Dir.
load_test_beam(Module, Beam) ->
cleanup_test_module(Module),
BeamPath = filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam"),
ok = file:write_file(BeamPath, Beam),
code:load_abs(filename:rootname(BeamPath)).
beam_entry(Module, ExpectedMd5, TargetBeam) ->
{ok, TargetMd5} = beam_md5(TargetBeam),
{ok, BeamZstd} = compress_test_beam(TargetBeam),
{
#{
module => Module,
expected_current_md5 => ExpectedMd5,
target_md5 => TargetMd5,
beam_zstd => BeamZstd
},
TargetMd5
}.
compress_test_beam(Beam) ->
try erlang:apply(ezstd, compress, [Beam, 3]) of
Compressed when is_binary(Compressed) -> {ok, Compressed};
{error, Reason} -> {error, Reason};
Other -> {error, Other}
catch
Class:Reason -> {error, {Class, Reason}}
end.
cleanup_test_module(Module) ->
_ = code:soft_purge(Module),
_ = code:purge(Module),
_ = code:delete(Module),
_ = code:purge(Module),
erase_hotpatch_loaded_md5(Module),
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".erl")),
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam")),
ok.
-spec erase_hotpatch_loaded_md5(atom()) -> ok.
erase_hotpatch_loaded_md5(Module) ->
_ = persistent_term:erase({?MODULE, loaded_md5, Module}),
ok.
-endif.
@@ -1,319 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_reconciler).
-typing([eqwalizer]).
-behaviour(gen_server).
-export([start_link/0, is_ready/0, status/0, reconcile_async/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
enabled = false :: boolean(),
build_sha = <<"dev">> :: binary(),
public_keys = #{} :: #{binary() => binary()},
applied_event_ids = [] :: [term()],
applied_count = 0 :: non_neg_integer(),
poll_interval_ms = 5000 :: pos_integer(),
last_error = undefined :: term()
}).
-type state() :: #state{}.
-define(SERVER, ?MODULE).
-define(POLL, poll).
-define(STARTUP_RECONCILE_RETRY_MS, 1000).
-spec start_link() -> gen_server:start_ret().
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
-spec is_ready() -> boolean().
is_ready() ->
gateway_hotpatch_runtime:is_ready().
-spec status() -> map().
status() ->
gateway_hotpatch_runtime:status().
-spec reconcile_async() -> ok.
reconcile_async() ->
gen_server:cast(?SERVER, reconcile).
-spec init([]) -> {ok, state()} | {stop, term()}.
init([]) ->
case gateway_hotpatch_runtime:is_enabled() of
false -> init_disabled();
true -> init_enabled()
end.
-spec handle_call(term(), {pid(), term()}, state()) -> {reply, term(), state()}.
handle_call(status, _From, State) ->
{reply, state_status(State, gateway_hotpatch_runtime:is_ready()), State};
handle_call(_Request, _From, State) ->
{reply, {error, unsupported_call}, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(reconcile, State) ->
{noreply, reconcile_and_publish(State)};
handle_cast(_Request, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info(?POLL, #state{enabled = true} = State) ->
NewState = reconcile_and_publish(State),
schedule_poll(NewState),
{noreply, NewState};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), state(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
-spec init_disabled() -> {ok, state()}.
init_disabled() ->
BuildSha = gateway_hotpatch_runtime:build_sha(),
State = #state{enabled = false, build_sha = BuildSha},
gateway_hotpatch_runtime:put_ready(true),
publish_status(State, true),
{ok, State}.
-spec init_enabled() -> {ok, state()} | {stop, term()}.
init_enabled() ->
gateway_hotpatch_runtime:put_ready(false),
BuildSha = gateway_hotpatch_runtime:build_sha(),
PollIntervalMs = runtime_pos_integer(hotpatch_poll_interval_ms, 5000),
PublicKeyConfig = gateway_hotpatch_runtime:get(hotpatch_public_keys, undefined),
case gateway_hotpatch_bundle:parse_public_keys(PublicKeyConfig) of
{ok, PublicKeys} when map_size(PublicKeys) > 0 ->
State0 = #state{
enabled = true,
build_sha = BuildSha,
public_keys = PublicKeys,
poll_interval_ms = max(1000, PollIntervalMs)
},
publish_status(State0, false),
init_enabled_connected(State0);
{ok, _Empty} ->
init_enabled_config_error(BuildSha, PollIntervalMs, missing_hotpatch_public_keys);
{error, Reason} ->
init_enabled_config_error(
BuildSha, PollIntervalMs, {invalid_hotpatch_public_keys, Reason}
)
end.
-spec init_enabled_config_error(binary(), pos_integer(), term()) -> {ok, state()}.
init_enabled_config_error(BuildSha, PollIntervalMs, Reason) ->
State = #state{
enabled = true,
build_sha = BuildSha,
poll_interval_ms = max(1000, PollIntervalMs),
last_error = Reason
},
gateway_hotpatch_runtime:put_ready(false),
publish_status(State, false),
logger:error("Gateway hotpatch configuration invalid: ~0tp", [Reason]),
schedule_poll(State),
{ok, State}.
-spec init_enabled_connected(state()) -> {ok, state()}.
init_enabled_connected(State0) ->
finish_enabled_startup(State0).
-spec finish_enabled_startup(state()) -> {ok, state()}.
finish_enabled_startup(State0) ->
TimeoutMs = runtime_pos_integer(hotpatch_startup_sync_timeout_ms, 30000),
DeadlineMs = erlang:monotonic_time(millisecond) + TimeoutMs,
finish_startup_reconcile(State0, startup_reconcile(State0, DeadlineMs)).
-spec finish_startup_reconcile(state(), {ok, state()} | {error, term()}) -> {ok, state()}.
finish_startup_reconcile(_State0, {ok, State}) ->
gateway_hotpatch_runtime:put_ready(true),
publish_status(State, true),
schedule_poll(State),
{ok, State};
finish_startup_reconcile(State0, {error, Reason}) ->
Error = {hotpatch_startup_sync_failed, Reason},
State = State0#state{last_error = Error},
gateway_hotpatch_runtime:put_ready(false),
publish_status(State, false),
logger:error("Gateway hotpatch startup sync failed; keeping node unready: ~0tp", [Error]),
schedule_poll(State),
{ok, State}.
-spec startup_reconcile(state(), integer()) -> {ok, state()} | {error, term()}.
startup_reconcile(State, DeadlineMs) ->
case reconcile_once(State) of
{ok, NewState} ->
{ok, NewState};
{error, Reason} ->
maybe_retry_startup_reconcile(State, DeadlineMs, Reason)
end.
-spec maybe_retry_startup_reconcile(state(), integer(), term()) ->
{ok, state()} | {error, term()}.
maybe_retry_startup_reconcile(State, DeadlineMs, Reason) ->
case erlang:monotonic_time(millisecond) >= DeadlineMs of
true ->
{error, Reason};
false ->
retry_startup_reconcile(State#state{last_error = Reason}, DeadlineMs, Reason)
end.
-spec retry_startup_reconcile(state(), integer(), term()) -> {ok, state()} | {error, term()}.
retry_startup_reconcile(State, DeadlineMs, Reason) ->
case gateway_retry_timer:wait_until(?STARTUP_RECONCILE_RETRY_MS, DeadlineMs) of
ok -> startup_reconcile(State, DeadlineMs);
expired -> {error, Reason};
{error, _InvalidDelay} -> {error, Reason}
end.
-spec reconcile_and_publish(state()) -> state().
reconcile_and_publish(#state{enabled = false} = State) ->
gateway_hotpatch_runtime:put_ready(true),
publish_status(State, true),
State;
reconcile_and_publish(State) ->
case reconcile_once(State) of
{ok, NewState} ->
gateway_hotpatch_runtime:put_ready(true),
publish_status(NewState, true),
NewState;
{error, Reason} ->
publish_reconcile_error(State, Reason)
end.
-spec reconcile_once(state()) -> {ok, state()} | {error, term()}.
reconcile_once(#state{build_sha = BuildSha} = State) ->
case gateway_hotpatch_store:connect() of
ok -> fetch_and_apply_events(BuildSha, State);
{error, Reason} -> {error, {store_connect_failed, Reason}}
end.
-spec fetch_and_apply_events(binary(), state()) -> {ok, state()} | {error, term()}.
fetch_and_apply_events(BuildSha, State) ->
case gateway_hotpatch_store:fetch_events(BuildSha) of
{ok, Events} -> apply_events(Events, State);
{error, Reason} -> {error, {fetch_events_failed, Reason}}
end.
-spec publish_reconcile_error(state(), term()) -> state().
publish_reconcile_error(State, {fetch_events_failed, _Reason} = Error) ->
ErrorState = State#state{last_error = Error},
Ready = gateway_hotpatch_runtime:is_ready(),
gateway_hotpatch_runtime:put_ready(Ready),
publish_status(ErrorState, Ready),
logger:warning("Gateway hotpatch fetch failed; keeping current readiness: ~0tp", [Error]),
ErrorState;
publish_reconcile_error(State, Reason) ->
ErrorState = State#state{last_error = Reason},
gateway_hotpatch_runtime:put_ready(false),
publish_status(ErrorState, false),
logger:error("Gateway hotpatch reconciliation failed: ~0tp", [Reason]),
ErrorState.
-spec apply_events([map()], state()) -> {ok, state()} | {error, term()}.
apply_events([], State) ->
{ok, State#state{last_error = undefined}};
apply_events([Event | Rest], State) ->
EventId = maps:get(event_id, Event, undefined),
case lists:member(EventId, State#state.applied_event_ids) of
true ->
apply_events(Rest, State);
false ->
apply_new_event(EventId, Event, Rest, State)
end.
-spec apply_new_event(term(), map(), [map()], state()) -> {ok, state()} | {error, term()}.
apply_new_event(EventId, Event, Rest, State) ->
case apply_event(Event, State) of
{ok, NewState} -> apply_events(Rest, NewState);
{error, Reason} -> {error, {event_apply_failed, EventId, Reason}}
end.
-spec apply_event(map(), state()) -> {ok, state()} | {error, term()}.
apply_event(Event, #state{public_keys = PublicKeys, build_sha = BuildSha} = State) ->
EventId = maps:get(event_id, Event, undefined),
BundleHash = maps:get(bundle_sha256, Event, <<>>),
_ = code:ensure_loaded(gateway_hotpatch_loader),
case gateway_hotpatch_bundle:decode_signed_event(Event, PublicKeys) of
{ok, Bundle} ->
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State);
{error, Reason} ->
audit_event(BuildSha, EventId, #{bundle_sha256 => BundleHash}, {error, Reason}),
{error, Reason}
end.
-spec apply_decoded_event(binary(), term(), binary(), map(), state()) ->
{ok, state()} | {error, term()}.
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State) ->
Summary0 = #{bundle_sha256 => BundleHash},
case gateway_hotpatch_loader:apply_bundle(Bundle) of
{ok, Summary} ->
handle_applied_event(BuildSha, EventId, Summary0, Summary, State);
{error, Reason} ->
audit_event(BuildSha, EventId, Summary0, {error, Reason}),
{error, Reason}
end.
-spec handle_applied_event(binary(), term(), map(), map(), state()) -> {ok, state()}.
handle_applied_event(BuildSha, EventId, Summary0, Summary, State) ->
AuditSummary = maps:merge(Summary0, Summary),
audit_event(BuildSha, EventId, AuditSummary, ok),
logger:notice("Applied gateway hotpatch event ~0tp summary=~0tp", [EventId, AuditSummary]),
{ok, State#state{
applied_event_ids = [EventId | State#state.applied_event_ids],
applied_count = State#state.applied_count + 1,
last_error = undefined
}}.
-spec audit_event(binary(), term(), map(), ok | {error, term()}) -> ok.
audit_event(BuildSha, EventId, Summary, Result) when is_binary(EventId) ->
case
gateway_hotpatch_store:audit_applied(
BuildSha, gateway_hotpatch_runtime:node_name(), EventId, Summary, Result
)
of
ok ->
ok;
{error, Reason} ->
logger:warning("Gateway hotpatch audit write failed: ~0tp", [Reason]),
ok
end;
audit_event(_BuildSha, _EventId, _Summary, _Result) ->
ok.
-spec publish_status(state(), boolean()) -> ok.
publish_status(State, Ready) ->
gateway_hotpatch_runtime:put_status(state_status(State, Ready)).
-spec state_status(state(), boolean()) -> map().
state_status(State, Ready) ->
#{
enabled => State#state.enabled,
ready => Ready,
build_sha => State#state.build_sha,
applied_count => State#state.applied_count,
applied_event_count => length(State#state.applied_event_ids),
last_error => State#state.last_error
}.
-spec schedule_poll(state()) -> ok.
schedule_poll(#state{poll_interval_ms = PollIntervalMs}) ->
erlang:send_after(PollIntervalMs, self(), ?POLL),
ok.
-spec runtime_pos_integer(atom(), pos_integer()) -> pos_integer().
runtime_pos_integer(Key, Default) ->
normalize_pos_integer(gateway_hotpatch_runtime:get(Key, Default), Default).
-spec normalize_pos_integer(term(), pos_integer()) -> pos_integer().
normalize_pos_integer(Value, _Default) when is_integer(Value), Value > 0 ->
Value;
normalize_pos_integer(_Value, Default) ->
Default.
@@ -1,78 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_runtime).
-typing([eqwalizer]).
-export([
build_sha/0,
node_name/0,
is_enabled/0,
get/2,
put_ready/1,
is_ready/0,
put_status/1,
status/0
]).
-define(READY_KEY, {gateway_hotpatch, ready}).
-define(STATUS_KEY, {gateway_hotpatch, status}).
-spec build_sha() -> binary().
build_sha() ->
case os:getenv("BUILD_SHA") of
false -> build_version();
"" -> build_version();
Value -> type_conv:ensure_binary(Value, <<"dev">>)
end.
-spec build_version() -> binary().
build_version() ->
case os:getenv("BUILD_VERSION") of
false -> <<"dev">>;
"" -> <<"dev">>;
Value -> type_conv:ensure_binary(Value, <<"dev">>)
end.
-spec node_name() -> binary().
node_name() ->
atom_to_binary(node(), utf8).
-spec is_enabled() -> boolean().
is_enabled() ->
case fluxer_gateway_env:get(hotpatch_enabled) of
true -> true;
_ -> false
end.
-spec get(atom(), term()) -> term().
get(Key, Default) ->
case fluxer_gateway_env:get(Key) of
undefined -> Default;
Value -> Value
end.
-spec put_ready(boolean()) -> ok.
put_ready(Ready) when is_boolean(Ready) ->
persistent_term:put(?READY_KEY, Ready),
ok.
-spec is_ready() -> boolean().
is_ready() ->
case persistent_term:get(?READY_KEY, true) of
false -> false;
_ -> true
end.
-spec put_status(map()) -> ok.
put_status(Status) ->
persistent_term:put(?STATUS_KEY, Status),
ok.
-spec status() -> map().
status() ->
persistent_term:get(?STATUS_KEY, #{
enabled => false,
ready => true,
build_sha => build_sha(),
applied_count => 0
}).
@@ -1,277 +0,0 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_hotpatch_store).
-typing([eqwalizer]).
-export([
connect/0,
fetch_events/1,
append_event/5,
append_event/6,
audit_applied/5,
normalize_event_rows/1
]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-define(STMT_FETCH_EVENTS, gateway_hotpatch_fetch_events_by_build).
-define(STMT_APPEND_EVENT, gateway_hotpatch_append_event).
-define(STMT_AUDIT_APPLIED, gateway_hotpatch_audit_applied).
-spec connect() -> ok | {error, term()}.
connect() ->
case gateway_hotpatch_runtime:get(hotpatch_cassandra_hosts, undefined) of
Hosts when is_list(Hosts), Hosts =/= [] -> connect_hosts(Hosts);
Hosts when is_binary(Hosts), byte_size(Hosts) > 0 ->
start_erlcass(binary_to_list(Hosts));
_ ->
{error, missing_hotpatch_cassandra_hosts}
end.
-spec connect_hosts(term()) -> ok | {error, term()}.
connect_hosts(Hosts) ->
case type_conv:to_list(Hosts) of
String when is_list(String), String =/= [] -> start_erlcass(String);
_ -> {error, missing_hotpatch_cassandra_hosts}
end.
-spec fetch_events(binary()) -> {ok, [map()]} | {error, term()}.
fetch_events(BuildSha) when is_binary(BuildSha) ->
case erlcass:execute(?STMT_FETCH_EVENTS, [BuildSha]) of
{ok, _Columns, Rows} -> {ok, normalize_event_rows(Rows)};
ok -> {ok, []};
{error, Reason} -> {error, Reason}
end.
-spec append_event(binary(), binary(), binary(), binary(), binary()) ->
{ok, binary()} | {error, term()}.
append_event(CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle) when
is_binary(CreatedBy),
is_binary(SignerKeyId),
is_binary(Signature),
is_binary(BundleSha256),
is_binary(Bundle)
->
BuildSha = gateway_hotpatch_runtime:build_sha(),
append_event(BuildSha, CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle).
-spec append_event(binary(), binary(), binary(), binary(), binary(), binary()) ->
{ok, binary()} | {error, term()}.
append_event(BuildSha, CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle) when
is_binary(BuildSha),
is_binary(CreatedBy),
is_binary(SignerKeyId),
is_binary(Signature),
is_binary(BundleSha256),
is_binary(Bundle)
->
case erlcass_uuid:gen_time() of
{ok, EventId} ->
Params = append_event_params(
BuildSha, EventId, CreatedBy, SignerKeyId, BundleSha256, Signature, Bundle
),
execute_append_event(EventId, Params);
{error, Reason} ->
{error, {event_id_failed, Reason}}
end.
-spec append_event_params(
binary(), binary(), binary(), binary(), binary(), binary(), binary()
) -> list().
append_event_params(BuildSha, EventId, CreatedBy, SignerKeyId, BundleSha256, Signature, Bundle) ->
[
BuildSha,
EventId,
1,
<<"beam_bundle">>,
CreatedBy,
SignerKeyId,
BundleSha256,
Signature,
Bundle
].
-spec execute_append_event(binary(), list()) -> {ok, binary()} | {error, term()}.
execute_append_event(EventId, Params) ->
case erlcass:execute(?STMT_APPEND_EVENT, Params) of
ok -> {ok, EventId};
{ok, _Columns, _Rows} -> {ok, EventId};
{error, Reason} -> {error, Reason}
end.
-spec audit_applied(binary(), binary(), binary(), map(), ok | {error, term()}) ->
ok | {error, term()}.
audit_applied(BuildSha, NodeName, EventId, Summary, Result) when
is_binary(BuildSha), is_binary(NodeName), is_binary(EventId), is_map(Summary)
->
ModuleCount = maps:get(module_count, Summary, 0),
BundleSha256 = maps:get(bundle_sha256, Summary, <<>>),
Status = audit_status(Result),
Error = audit_error(Result),
Params = [BuildSha, NodeName, EventId, ModuleCount, BundleSha256, Status, Error],
case erlcass:execute(?STMT_AUDIT_APPLIED, Params) of
ok -> ok;
{ok, _Columns, _Rows} -> ok;
{error, Reason} -> {error, Reason}
end.
-spec normalize_event_rows([list()]) -> [map()].
normalize_event_rows(Rows) when is_list(Rows) ->
lists:filtermap(fun normalize_event_row/1, Rows).
-spec start_erlcass(string()) -> ok | {error, term()}.
start_erlcass(Hosts) ->
Keyspace = gateway_hotpatch_runtime:get(hotpatch_cassandra_keyspace, <<"fluxer">>),
Port = gateway_hotpatch_runtime:get(hotpatch_cassandra_port, 9042),
Options0 = [
{contact_points, type_conv:ensure_binary(Hosts)},
{port, Port},
{latency_aware_routing, true},
{token_aware_routing, true},
{tcp_nodelay, true},
{tcp_keepalive, {true, 60}},
{connect_timeout, 5000},
{request_timeout, 5000},
{retry_policy, {default, true}},
{default_consistency_level, 6}
],
case maybe_credentials(Options0) of
{ok, Options} -> start_erlcass_with_options(Keyspace, Options);
{error, Reason} -> {error, Reason}
end.
-spec start_erlcass_with_options(binary(), list()) -> ok | {error, term()}.
start_erlcass_with_options(Keyspace, Options) ->
application:set_env(erlcass, keyspace, Keyspace),
application:set_env(erlcass, cluster_options, Options),
application:set_env(erlcass, log_level, 2),
case application:ensure_all_started(erlcass) of
{ok, _Apps} -> prepare_statements();
{error, {erlcass, {already_started, erlcass}}} -> prepare_statements();
{error, Reason} -> {error, {erlcass_start_failed, Reason}}
end.
-spec prepare_statements() -> ok | {error, term()}.
prepare_statements() ->
Statements = [
{?STMT_FETCH_EVENTS, <<
"SELECT event_id, schema_version, kind, created_by, signer_key_id, "
"bundle_sha256, signature, bundle "
"FROM gateway_hotpatch_events_by_build WHERE build_sha = ?"
>>},
{?STMT_APPEND_EVENT, <<
"INSERT INTO gateway_hotpatch_events_by_build "
"(build_sha, event_id, schema_version, kind, created_at, created_by, "
"signer_key_id, bundle_sha256, signature, bundle) "
"VALUES (?, ?, ?, ?, toTimestamp(now()), ?, ?, ?, ?, ?)"
>>},
{?STMT_AUDIT_APPLIED, <<
"INSERT INTO gateway_hotpatch_applied_by_node "
"(build_sha, node_name, event_id, applied_at, module_count, bundle_sha256, "
"status, error) "
"VALUES (?, ?, ?, toTimestamp(now()), ?, ?, ?, ?)"
>>}
],
prepare_statements(Statements).
-spec prepare_statements([{atom(), binary()}]) -> ok | {error, term()}.
prepare_statements([]) ->
ok;
prepare_statements([{Id, Query} | Rest]) ->
case prepare_statement(Id, Query) of
ok -> prepare_statements(Rest);
{error, Reason} -> {error, Reason}
end.
-spec prepare_statement(atom(), binary()) -> ok | {error, term()}.
prepare_statement(Id, Query) ->
case erlcass:add_prepare_statement(Id, Query) of
ok -> ok;
{error, already_exist} -> ok;
{error, Reason} -> {error, {prepare_failed, Id, Reason}}
end.
-spec maybe_credentials(list()) -> {ok, list()} | {error, term()}.
maybe_credentials(Options) ->
Username = gateway_hotpatch_runtime:get(hotpatch_cassandra_username, undefined),
Password = gateway_hotpatch_runtime:get(hotpatch_cassandra_password, undefined),
case {Username, Password} of
{U, P} when is_binary(U), byte_size(U) > 0, is_binary(P), byte_size(P) > 0 ->
{ok, [{credentials, {U, P}} | Options]};
_ ->
{error, missing_hotpatch_cassandra_credentials}
end.
-spec normalize_event_row(list()) -> {true, map()} | false.
normalize_event_row([
EventId,
SchemaVersion,
Kind,
CreatedBy,
SignerKeyId,
BundleSha256,
Signature,
Bundle
]) when
is_binary(EventId),
is_binary(SignerKeyId),
is_binary(BundleSha256),
is_binary(Signature),
is_binary(Bundle)
->
{true, #{
event_id => EventId,
schema_version => SchemaVersion,
kind => Kind,
created_by => CreatedBy,
signer_key_id => SignerKeyId,
bundle_sha256 => BundleSha256,
signature => Signature,
bundle => Bundle
}};
normalize_event_row(_Row) ->
false.
-spec audit_status(ok | {error, term()}) -> binary().
audit_status(ok) -> <<"ok">>;
audit_status({error, _Reason}) -> <<"error">>.
-spec audit_error(ok | {error, term()}) -> binary().
audit_error(ok) ->
<<>>;
audit_error({error, Reason}) ->
iolist_to_binary(io_lib:format("~0tp", [Reason])).
-ifdef(TEST).
normalize_event_rows_test() ->
EventId = <<1:128>>,
Row = [
EventId,
1,
<<"beam_bundle">>,
<<"hampus">>,
<<"ops">>,
<<2:256>>,
<<3:512>>,
<<"bundle">>
],
?assertEqual(
[
#{
event_id => EventId,
schema_version => 1,
kind => <<"beam_bundle">>,
created_by => <<"hampus">>,
signer_key_id => <<"ops">>,
bundle_sha256 => <<2:256>>,
signature => <<3:512>>,
bundle => <<"bundle">>
}
],
normalize_event_rows([Row, [invalid]])
).
-endif.
@@ -98,7 +98,7 @@ fallback_owner_for_role(Role) ->
-spec is_ready() -> boolean().
is_ready() ->
not is_draining() andalso gateway_hotpatch_reconciler:is_ready().
not is_draining().
-spec is_draining() -> boolean().
is_draining() ->
@@ -23,7 +23,6 @@ render_metrics() ->
render_cluster_counters(),
render_process_counts(),
render_push_dispatcher_stats(),
render_hotpatch_metrics(),
render_vm_metrics()
].
@@ -256,33 +255,6 @@ render_push_dispatcher_stats() ->
]
end.
-spec render_hotpatch_metrics() -> iolist().
render_hotpatch_metrics() ->
Status = safe_apply_map(fun gateway_hotpatch_reconciler:status/0),
Ready = maps:get(ready, Status, true),
Enabled = maps:get(enabled, Status, false),
AppliedCount = maps:get(applied_count, Status, 0),
[
format_metric(
<<"fluxer_gateway_hotpatch_enabled">>,
<<"gauge">>,
<<"Gateway hotpatch reconciler enabled">>,
bool_metric(Enabled)
),
format_metric(
<<"fluxer_gateway_hotpatch_ready">>,
<<"gauge">>,
<<"Gateway hotpatch reconciliation readiness">>,
bool_metric(Ready)
),
format_metric(
<<"fluxer_gateway_hotpatch_applied_events_total">>,
<<"counter">>,
<<"Gateway hotpatch events applied on this node">>,
integer_to_binary(AppliedCount)
)
].
-spec render_vm_metrics() -> iolist().
render_vm_metrics() ->
Memory = safe_apply_list(fun erlang:memory/0),
@@ -376,7 +348,3 @@ format_labeled_series(Name, Type, Help, LabelValues) ->
|| {Label, Value} <- LabelValues
]
].
-spec bool_metric(term()) -> binary().
bool_metric(true) -> <<"1">>;
bool_metric(_) -> <<"0">>.
@@ -159,7 +159,7 @@ flush_pending_syncs_clears_empty_batch_test() ->
State = #{?SYNC_BATCH_STATE_KEY => #{pending_list_ids => #{}}},
?assertEqual(#{}, flush_pending_syncs(State)).
queue_list_sync_drains_pre_hotpatch_pending_batch_test() ->
queue_list_sync_drains_preexisting_pending_batch_test() ->
Ref = erlang:send_after(60000, self(), ?FLUSH_SYNC_BATCH_MSG),
State = #{
?SYNC_BATCH_STATE_KEY => #{