diff options
Diffstat (limited to 'apps')
59 files changed, 1393 insertions, 157 deletions
diff --git a/apps/dreki/include/dreki_otel.hrl b/apps/dreki/include/dreki_otel.hrl new file mode 100644 index 0000000..e784874 --- /dev/null +++ b/apps/dreki/include/dreki_otel.hrl @@ -0,0 +1,11 @@ +-include_lib("opentelemetry_api/include/opentelemetry.hrl"). +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). + +-define(FUN_NAME, binary:list_to_bin([ + atom_to_binary(?MODULE), <<":">>, + atom_to_binary(?FUNCTION_NAME), <<"/">>, + integer_to_binary(?FUNCTION_ARITY) + ]) +). + +-define(FUN_NAME(D), binary:list_to_bin([?FUN_NAME, atom_to_binary(D)])). diff --git a/apps/dreki/include/dreki_plum.hrl b/apps/dreki/include/dreki_plum.hrl index bea65aa..aed2e05 100644 --- a/apps/dreki/include/dreki_plum.hrl +++ b/apps/dreki/include/dreki_plum.hrl @@ -5,14 +5,13 @@ %% dreki_store tabs -define(PLUM_DB_STORE_TASKS_TAB, dreki_tasks). - %% indices tabs -define(PLUM_DB_IDX_ROLE_TAB, 'dreki_idx:roles'). -define(PLUM_DB_IDX_TAGS_TAB, 'dreki_idx:tags'). -define(PLUM_DB_PREFIXES, [ - {?PLUM_DB_REGIONS_TAB, ram_disk}, - {?PLUM_DB_NODES_TAB, ram_disk}, + {?PLUM_DB_REGIONS_TAB, disk}, + {?PLUM_DB_NODES_TAB, disk}, {?PLUM_DB_PATHS_TAB, ram_disk}, {?PLUM_DB_STORES_TAB, ram_disk}, @@ -23,4 +22,3 @@ {?PLUM_DB_IDX_ROLE_TAB, disk}, {?PLUM_DB_IDX_TAGS_TAB, disk} ]). - diff --git a/apps/dreki/src/dreki.app.src b/apps/dreki/src/dreki.app.src index 78009bf..2e152a7 100644 --- a/apps/dreki/src/dreki.app.src +++ b/apps/dreki/src/dreki.app.src @@ -8,9 +8,15 @@ stdlib, mnesia, logger_colorful, + ipee, opentelemetry, opentelemetry_api, opentelemetry_exporter, + opentelemetry_logger_metadata, + telemetry, + opentelemetry_telemetry, + prometheus, + genlib, uuid, mnesia_rocksdb, plum_db, diff --git a/apps/dreki/src/dreki_app.erl b/apps/dreki/src/dreki_app.erl index a1259a7..0442b4c 100644 --- a/apps/dreki/src/dreki_app.erl +++ b/apps/dreki/src/dreki_app.erl @@ -31,6 +31,8 @@ before_start(Type, Args) -> logger:set_application_level(dreki_web, debug), logger:set_application_level(partisan, info), logger:set_application_level(plum_db, info), + logger:set_handler_config(default, level, debug), + opentelemetry_logger_metadata:setup(), ?LOG_NOTICE(#{message => "Dreki starting...."}), application:stop(partisan), ok = dreki_config:init(Args), @@ -48,6 +50,7 @@ after_start() -> ok = setup_event_manager(), ok = dreki_store:start(), ?LOG_NOTICE(#{message => "Dreki Ready"}), + ok = dreki_config:set([dreki, status], ready), dreki_event_manager:notify(dreki_ready), ok. @@ -70,12 +73,11 @@ setup_event_manager() -> Mod = partisan_peer_service:manager(), Mod:on_up('_', fun(Node) -> - dreki_event_manager:notify({peer_up, Node}) + dreki_event_manager:notify({partisan_peer_service, peer_up, Node}) end), Mod:on_down('_', fun(Node) -> - dreki_event_manager:notify({peer_down, Node}) + dreki_event_manager:notify({partisan_peer_service, peer_down, Node}) end), ok. - diff --git a/apps/dreki/src/dreki_config.erl b/apps/dreki/src/dreki_config.erl index 69df2d6..457f559 100644 --- a/apps/dreki/src/dreki_config.erl +++ b/apps/dreki/src/dreki_config.erl @@ -2,8 +2,14 @@ -include_lib("kernel/include/logger.hrl"). -include_lib("partisan/include/partisan.hrl"). -include("dreki_plum.hrl"). +-include("dreki_otel.hrl"). + +-compile({no_auto_import,[get/0]}). + -export([init/1]). +-export([get/1]). +-export([set/2]). -define(CONFIG, [ %% All stolen from bondy as partisan isn't that well documented, eh. @@ -44,15 +50,29 @@ -define(PT, dreki_config_cache). init(_Args) -> - persistent_term:put(?PT, application:get_all_env(dreki)), + persistent_term:put(?PT, [{dreki, application:get_all_env(dreki)}, + {dreki_web, application:get_all_env(dreki_web) + }]), ok = set_app_configs(?CONFIG), - ?LOG_INFO(#{message => "Configured Dreki and dependencies"}), + ?LOG_NOTICE(#{message => "Configured Dreki and dependencies"}), ok = partisan_config:init(), ok. +get() -> + persistent_term:get(?PT). + +get(Key) -> + ?with_span(?FUN_NAME, #{}, fun (_) -> key_value:get(Key, get()) end). + +set(Key, Value) -> + ?with_span(?FUN_NAME, #{}, + fun (_) -> + persistent_term:put(?PT, key_value:put(Key, Value, get())), + ok + end). + set_app_configs(Configs) -> lists:foreach(fun ({App, Params}) -> [application:set_env(App, Key, Value) || {Key, Value} <- Params] end, Configs), ok. - diff --git a/apps/dreki/src/dreki_freebsd_schemas.erl b/apps/dreki/src/dreki_freebsd_schemas.erl new file mode 100644 index 0000000..de97ded --- /dev/null +++ b/apps/dreki/src/dreki_freebsd_schemas.erl @@ -0,0 +1,59 @@ +-module(dreki_freebsd_schemas). + +-export([schema/1]). + +schema(<<"dreki.v1.freebsd.jails.list">>) -> + #{ + <<"@schema">> => "dreki.v1.freebsd.jails.list", + version => 'draft-06', + title => <<"List running jails ID/Names">>, + properties => #{} + }; +schema(<<"dreki.v1.freebsd.jails.get">>) -> + #{ + <<"@schema">> => "dreki.v1.freebsd.jails.get", + version => 'draft-06', + title => <<"Get jail information">>, + properties => #{ + jail_id => #{type => integer} + }, + required => [jail_id] + }; +schema(<<"dreki.v1.freebsd.jails.start">>) -> + #{ + <<"@schema">> => "dreki.v1.freebsd.jails.start", + version => 'draft-06', + title => <<"Start a jail">>, + properties => #{ + name => #{type => string}, + path => #{type => string}, + hostname => #{type => string}, + params => #{type => object} + }, + required => [name, path, hostname, params] + }; +schema(<<"dreki.v1.freebsd.jails.exec">>) -> + #{ + <<"@schema">> => "dreki.v1.freebsd.jails.exec", + version => 'draft-06', + title => <<"Execute a command in a jail">>, + properties => #{ + jail_id => #{type => string}, + command => #{type => string}, + args => #{type => array}, + env => #{type => object} + }, + required => [id, command, args, env] + }; +schema(<<"dreki.v1.exec">>) -> + #{ + <<"@schema">> => "dreki.v1.exec", + version => 'draft-06', + title => <<"Execute a command">>, + properties => #{ + command => #{type => string}, + args => #{type => array}, + env => #{type => object} + }, + required => [command, args, env] + }. diff --git a/apps/dreki/src/dreki_plum.erl b/apps/dreki/src/dreki_plum.erl index 9c03e46..dccdad7 100644 --- a/apps/dreki/src/dreki_plum.erl +++ b/apps/dreki/src/dreki_plum.erl @@ -8,7 +8,6 @@ before_start() -> %% We temporarily disable plum_db's AAE to avoid rebuilding hashtrees %% until we are ready to do it ok = suspend_aae(), - logger:debug("-- DISABLED AAE ! --"), _ = application:ensure_all_started(plum_db, permanent), ok. @@ -27,9 +26,7 @@ suspend_aae() -> true -> ok = application:set_env(plum_db, priv_aae_enabled, true), ok = application:set_env(plum_db, aae_enabled, false), - ?LOG_NOTICE(#{ - description => "Temporarily disabled active anti-entropy (AAE) during initialisation" - }), + ?LOG_NOTICE(#{message => "Temporarily disabled plum_db aae during initialisation"}), ok; false -> ok @@ -40,9 +37,7 @@ restore_aae() -> true -> %% plum_db should have started so we call plum_db_config ok = plum_db_config:set(aae_enabled, true), - ?LOG_NOTICE(#{ - description => "Active anti-entropy (AAE) re-enabled" - }), + ?LOG_NOTICE(#{message => "plum_db aae re-enabled"}), ok; false -> ok @@ -51,11 +46,10 @@ restore_aae() -> maybe_wait_for_plum_db_partitions() -> case wait_for_partitions() of true -> - %% We block until all partitions are initialised - ?LOG_NOTICE(#{ - description => "Application master is waiting for plum_db partitions to be initialised" - }), - plum_db_startup_coordinator:wait_for_partitions(); + ?LOG_NOTICE(#{domain => [dreki_plum], message => "Waiting for plum_db partitions to be initialised"}), + ok = plum_db_startup_coordinator:wait_for_partitions(), + ?LOG_NOTICE(#{domain => [dreki_plum], message => "plum_db partitions initialised"}), + ok; false -> ok end. @@ -64,10 +58,10 @@ maybe_wait_for_plum_db_hashtrees() -> case wait_for_hashtrees() of true -> %% We block until all hashtrees are built - ?LOG_NOTICE(#{ - description => "Application master is waiting for plum_db hashtrees to be built" - }), - plum_db_startup_coordinator:wait_for_hashtrees(); + ?LOG_NOTICE(#{domain => [dreki_plum], message => "Waiting for plum_db hashtrees to be built"}), + ok = plum_db_startup_coordinator:wait_for_hashtrees(), + ?LOG_NOTICE(#{domain => [dreki_plum], message => "plum_db hashtrees built"}), + ok; false -> ok end, @@ -88,9 +82,7 @@ maybe_wait_for_aae_exchange() -> %% We have not yet joined a cluster, so we finish ok; Peers -> - ?LOG_NOTICE(#{ - description => "Application master is waiting for plum_db AAE to perform exchange" - }), + ?LOG_NOTICE(#{domain => [plum_db], message => "Waiting for plum_db AAE to perform exchange"}), %% We are in a cluster, we randomnly pick a peer and %% perform an AAE exchange [Peer|_] = lists_utils:shuffle(Peers), diff --git a/apps/dreki/src/dreki_sup.erl b/apps/dreki/src/dreki_sup.erl index c1aa636..60b33e5 100644 --- a/apps/dreki/src/dreki_sup.erl +++ b/apps/dreki/src/dreki_sup.erl @@ -16,20 +16,12 @@ start_link() -> supervisor:start_link({local, ?SERVER}, ?MODULE, []). -%% sup_flags() = #{strategy => strategy(), % optional -%% intensity => non_neg_integer(), % optional -%% period => pos_integer()} % optional -%% child_spec() = #{id => child_id(), % mandatory -%% start => mfargs(), % mandatory -%% restart => restart(), % optional -%% shutdown => shutdown(), % optional -%% type => worker(), % optional -%% modules => modules()} % optional init([]) -> SupFlags = #{strategy => one_for_all, intensity => 10, period => 5}, ChildSpecs = [ + #{id => drekid, start => {drekid, start_link, [[]]}}, #{id => dreki_event_manager, start => {dreki_event_manager, start_link, [[]]}}, #{id => dreki_world_server, start => {dreki_world_server, start_link, [[]]}}, #{id => dreki_node_server, start => {dreki_node_server, start_link, [[]]}} diff --git a/apps/dreki/src/drekid.erl b/apps/dreki/src/drekid.erl new file mode 100644 index 0000000..73beffd --- /dev/null +++ b/apps/dreki/src/drekid.erl @@ -0,0 +1,95 @@ +-module(drekid). +-behaviour(partisan_gen_fsm). +-include_lib("kernel/include/logger.hrl"). +-include_lib("dreki_otel.hrl"). +-compile({no_auto_import, [get/0]}). +-export([get/0]). +-export([request/1, request/2, request/3]). +-export([start_link/1]). +-export([init/1, waiting/2, handle_info/3, handle_event/3, handle_sync_event/4]). +-define(DREKID_PT, drekid). +-record(drekid, {node, pid}). + +-spec get() -> {ok, pid()} | {error, drekid_unavailable}. +get() -> + persistent_term:get(drekid, {error, drekid_unavailable}). + +request(Fun) -> + request(Fun, []). + +request(Fun, Args) -> + request(Fun, Args, 5000). + +request(Fun, Args, Timeout) when is_binary(Fun) -> + ?with_span(?FUN_NAME, fun(_SpanCtx) -> + send_request(Fun, Args, Timeout) + end). + +send_request(Fun, Args, Timeout) -> + ?set_attributes(#{function => Fun, args => Args, timeout => Timeout}), + case get() of + {ok, Pid} -> + Ref = make_ref(), + logger:info("drekid_request: ~p ~p to ~p", [Fun, Args, Pid]), + Pid ! {drekid_request_v1, self(), Ref, Fun, Args, Timeout}, + ?with_span(?FUN_NAME(await_request), fun(_) -> + await_request(Ref, Timeout) + end); + Error -> + Error + end. + +await_request(Ref, Timeout) -> + receive + {drekid_response_v1, Ref, Result} -> + case Result of + {ok, Data = {_, _}} -> + Data; + {ok, Data} -> + {ok, Data}; + {error, Error} -> + {error, {drekid_error, Error}} + end + after Timeout -> + {error, {drekid_error, timeout}} + end. + +start_link(Args) -> + partisan_gen_fsm:start_link({local, ?MODULE}, ?MODULE, Args, []). + +%% + +init(_Args) -> + ?LOG_DEBUG("drekid initialized"), + {ok, waiting, #drekid{}, 0}. + +waiting(timeout, Data) -> + persistent_term:put(drekid, {error, drekid_unavailable}), + {next_state, waiting, Data}; +waiting({drekid, {connect, Node, Pid}}, Data) -> + Data0 = Data#drekid{node = Node, pid = Pid}, + monitor_node(Node, true), + Pid ! {drekid, hello, node(), self()}, + ?LOG_NOTICE("drekid ready, node: ~p ~p", [Node, Pid]), + persistent_term:put(drekid, {ok, Pid}), + {next_state, ready, Data}. + +down(timeout, {Reason, State, Data}) -> + ?LOG_ERROR(#{code => drekid_down, reason => Reason, state => State}), + {next_state, waiting, Data#drekid{node = undefined, pid = undefined}}. + +handle_info({drekid, Msg}, waiting, Data) -> + waiting({drekid, Msg}, Data); +handle_info({nodedown, Node}, State, Data = #drekid{node = Node}) -> + {next_state, down, {nodedown, State, Data}, 0}; +handle_info(Info, State, Data) -> + ?LOG_ERROR(#{code => unhandled_info, state => State, info => Info}), + {stop, badinfo, Data}. + +handle_event(Event, State, Data) -> + ?LOG_ERROR(#{code => unhandled_event, state => State, event => Event}), + {stop, badevent, Data}. + +handle_sync_event(Event, From, State, Data) -> + ?LOG_ERROR(#{code => unhandled_sync_event, state => State, event => Event}), + {stop, badevent, badevent, Data}. diff --git a/apps/dreki/src/drekid_tasks_store.erl b/apps/dreki/src/drekid_tasks_store.erl new file mode 100644 index 0000000..e16182a --- /dev/null +++ b/apps/dreki/src/drekid_tasks_store.erl @@ -0,0 +1,71 @@ +-module(drekid_tasks_store). +-behaviour(dreki_store_backend). + +-export([install/0]). +-export([start/0, start/5, checkout/1, checkin/1, stop/0, stop/1]). +-export([valid_store/5]). +-export([list/1, count/1, exists/2, get/2, create/2, update/2, delete/2]). + +tasks() -> + Tasks = #{<<"dreki.v1.exec">> => #{id => <<"dreki.v1.exec">>, module => drekid_task_handler, params => #{}}}, + FreeBSD = [<<"dreki.v1.freebsd.jails.list">>, + <<"dreki.v1.freebsd.jails.get">>, + <<"dreki.v1.freebsd.jails.start">>, + <<"dreki.v1.freebsd.jails.exec">> + ], + Tasks0 = lists:foldr(fun (Id, Acc) -> + Task = #{id => Id, module => drekid_task_handler, params => dreki_freebsd_schemas:schema(Id)}, + maps:put(Id, Task, Acc) + end, Tasks, FreeBSD), + Tasks0. + +urn() -> + Urn = dreki_node:urn(), + <<Urn/binary, "::tasks:drekid">>. + +install() -> + dreki_store:create_store(urn(), ?MODULE, #{}, #{}). + +valid_store(<<"tasks">>, _Loc, _Name, _NSMod, _Args) -> + ok; +valid_store(_Ns, _Loc, _Name, _NSMod, _Args) -> + error. + +start() -> + ok. + +start(_Ns, _NsMod, _Loc, _XUrn, Args) -> + {ok, Args}. + +checkout(Args) -> + {ok, Args}. + +checkin(Args) -> + ok. + +stop() -> + ok. + +stop(Args) -> + ok. + +list(_) -> + {ok, [V || {K, V} <- maps:to_list(tasks())]}. + +get(_, Id) -> + maps:get(Id, tasks(), not_found). + +count(_) -> + {ok, maps:size(tasks())}. + +exists(_, Id) -> + {ok, maps:is_key(Id, tasks())}. + +create(_, _) -> + {error, not_supported}. + +update(_, _) -> + {error, not_supported}. + +delete(_, _) -> + {error, not_supported}. diff --git a/apps/dreki/src/funs/dreki_fun_exec.erl b/apps/dreki/src/funs/dreki_fun_exec.erl new file mode 100644 index 0000000..d704e80 --- /dev/null +++ b/apps/dreki/src/funs/dreki_fun_exec.erl @@ -0,0 +1,28 @@ +-module(dreki_fun_exec). +-behaviour(dreki_funs). +-export([schemas/0]). + +schemas() -> + #{<<"exec">> => #{default_version => <<"1.0">>, <<"1.0">> => schemas('1.0')}}. + +schemas('1.0') -> + #{ + version => 'draft-06', + title => <<"Execute command">>, + type => object, + properties => #{ + <<"id">> => #{type => string, + <<"dreki:form">> => #{default => generate_id}}, + <<"name">> => #{type => string}, + <<"description">> => #{type => string, + <<"dreki:form">> => #{ + input => textarea, + textarea_mode => markdown + }}, + <<"command">> => #{type => string, <<"dreki:form">> => #{ + input_style => monospace, + placeholder => <<"/bin/true">> + }} + }, + required => [id, name, command] + }. diff --git a/apps/dreki/src/funs/dreki_funs.erl b/apps/dreki/src/funs/dreki_funs.erl new file mode 100644 index 0000000..1d15f21 --- /dev/null +++ b/apps/dreki/src/funs/dreki_funs.erl @@ -0,0 +1,31 @@ +-module(dreki_funs). +-include("dreki.hrl"). + +-behaviour(dreki_store_namespace). +-export([start/0, valid_store/4, format_item/1, schemas/0]). + +-callback(schemas() -> #{}). + +start() -> + ok. + +valid_store(_Namespace, _Location, _Name, _BackendMod) -> + ok. + +format_item(Item) -> + ok. + +handlers() -> + [dreki_fun_exec]. + +-record(?MODULE, { + id, + version, + name, + handler, + content + }). + +schemas() -> + Schemas = lists:foldr(fun (Handler, Acc) -> maps:merge(Acc, Handler:schemas()) end, #{}, handlers()), + maps:put(default, <<"exec:1.0">>, Schemas). diff --git a/apps/dreki/src/dreki_node.erl b/apps/dreki/src/node/dreki_node.erl index 87dbc73..5ed7fac 100644 --- a/apps/dreki/src/dreki_node.erl +++ b/apps/dreki/src/node/dreki_node.erl @@ -1,10 +1,11 @@ -module(dreki_node). -include("dreki.hrl"). --include_lib("opentelemetry_api/include/otel_tracer.hrl"). +-include_lib("kernel/include/logger.hrl"). +-include("dreki_otel.hrl"). -behaviour(partisan_gen_fsm). -compile({no_auto_import,[get/0]}). -export([get/0, urn/0, stores/0]). --export([rpc/4, rpc/5]). +-export([rpc/4, rpc/5, remote_rpc/7]). -export([uri/0]). % deprecated -export([parents/0, parents/1, parent/0, parent/1]). -export([neighbours/0, neighbours/1]). @@ -18,17 +19,64 @@ rpc(Path, Mod, Fun, Args) -> -spec rpc(dreki_urn(), module(), function(), Args :: [], #{timeout => non_neg_integer()}) -> {ok, any()} | {error, rpc_error()}. rpc(Path, Mod, Fun, Args, #{timeout := Timeout}) -> - ?with_span(<<"dreki_node:rpc">>, #{}, fun(ChildSpanCtx) -> + Myself = self(), + ?with_span(?FUN_NAME, fun(SpanCtx) -> + ?set_attributes(#{mod => Mod, fn => Fun, remote_node_path => Path}), + ?LOG_INFO("Ctx ~p ~p ~p ~p", [?FUN_NAME, otel_ctx:get_current(), otel_tracer:current_span_ctx(), SpanCtx]), case dreki_world_dns:node_param(dreki_world:path_to_domain(Path), node_name) of - {ok, NodeName} -> - case partisan_rpc_backend:call(NodeName, Mod, Fun, Args, Timeout) of - {badrpc, Error} -> {error, {rpc_error, Path, Error}}; - Result -> Result - end; - Error -> Error - end + {ok, NodeName} -> + ?set_attribute(remote_node, NodeName), + Ctx = otel_ctx:get_current(), + ChildSpanCtx = ?start_span(?FUN_NAME(remote), #{}), + TraceId = otel_span:hex_trace_id(SpanCtx), + SpanId = otel_span:hex_span_id(SpanCtx), + case partisan_rpc_backend:call(NodeName, ?MODULE, remote_rpc, [Myself, Mod, Fun, Args, Timeout, TraceId, SpanId], Timeout) of + {badrpc, RpcError} -> + ?set_status(error, <<"RPC_ERROR">>), + {error, {rpc, RpcError}}; + Error = {error, _} -> + ?set_status(error, <<"RESULT">>), + Error; + Result -> + ?set_status(ok), + Result + end; + Error -> + ?set_status(error, <<"NODE_NOT_FOUND">>), + Error + end end). +remote_rpc(Pid, Mod, Fun, Args, Timeout, TraceId, SpanId) -> + ParentCtx = otel_tracer:from_remote_span(TraceId, SpanId, 1), + otel_tracer:with_span(ParentCtx, opentelemetry:get_tracer(), ?FUN_NAME, #{}, + fun (Ctx) -> remote_rpc(Pid, Mod, Fun, Args, Timeout, Ctx) end). + +remote_rpc(Pid, Mod, Fun, Args, Timeout, Ctx) -> + ?set_attributes(#{remote_node => node(), module => Mod, function => Fun}), + try apply(Mod, Fun, Args) of + {badrpc, RpcError} -> + ?set_status(error, <<"RPC_ERROR">>), + {error, {rpc, RpcError}}; + Error = {error, _} -> + ?set_status(error, <<"RESULT">>), + Error; + Result -> + Result + catch + throw:Term:Stacktrace -> + ?set_status(error, <<"THROW">>), + {error, {throw, Term, Stacktrace}}; + exit:Reason:Stacktrace -> + ?set_status(error, <<"EXIT">>), + {error, {exit, Reason, Stacktrace}}; + error:Reason:Stacktrace -> + ?set_status(error, <<"RUNTIME">>), + {error, {runtime_error, Reason, Stacktrace}} + after + ?end_span() + end. + get() -> {ok, Node} = dreki_world:get_node(dreki_world:node()), Node. @@ -100,4 +148,3 @@ ensure_local_node() -> create_local_node() -> dreki_world:create_node(uri(), #{}). - diff --git a/apps/dreki/src/dreki_node_server.erl b/apps/dreki/src/node/dreki_node_server.erl index c8bca51..c8bca51 100644 --- a/apps/dreki/src/dreki_node_server.erl +++ b/apps/dreki/src/node/dreki_node_server.erl diff --git a/apps/dreki/src/node/rebar.conf b/apps/dreki/src/node/rebar.conf new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/apps/dreki/src/node/rebar.conf diff --git a/apps/dreki/src/requests/dreki_requests.erl b/apps/dreki/src/requests/dreki_requests.erl new file mode 100644 index 0000000..d970dbd --- /dev/null +++ b/apps/dreki/src/requests/dreki_requests.erl @@ -0,0 +1,97 @@ +-module(dreki_requests). +-behaviour(dreki_store_namespace). +-export([start/0, version/0, valid_store/4, format_item/1, actions/0, actions/1, schemas/0, new/0, as_tuple/1, as_map/1]). +-export([record_name/0, record_attributes/0]). +-export([after_create/1]). +-export([new/1, create/2]). +-export([install/0]). + +-record(?MODULE, { + id, + version, + schema, + task_urn, + identity_urn, + node, + params + }). + +create(#{'@ns' := <<"tasks">>, '@location' := Loc, '@id' := TaskUrn}, Params0) -> + LocalStore = <<Loc/binary, "::requests:local">>, + Params = Params0#{task_urn => TaskUrn}, + dreki_store:create(LocalStore, Params). + +install() -> + NodeUrn = dreki_node:urn(), + Urn = <<NodeUrn/binary, "::requests:local">>, + dreki_store:create_store(Urn, dreki_mnesia_store, #{}, #{}). + +record_name() -> + ?MODULE. + +record_attributes() -> + record_info(fields, ?MODULE). + +version() -> + 1. + +start() -> + ok. + +valid_store(_Ns, _Loc, _Name, _BackendMod) -> + ok. + +format_item(Item) -> + ok. + +actions() -> + []. + +actions(_) -> + []. + +schemas() -> + #{default => <<"request">>, + <<"request">> => #{ + default_version => <<"1.0">>, + <<"1.0">> => schemas(<<"request">>, <<"1.0">>) + } + }. + +schemas(<<"request">>, <<"1.0">>) -> + #{ + version => 'draft-06', + title => <<"Run Request">>, + type => object, + properties => #{ + id => #{type => string, <<"dreki:form">> => #{readonly => true}}, + node => #{type => string}, + params => #{type => object} + }, + required => [node, params] + }. + +new() -> + schemas(<<"request">>, <<"1.0">>). + +new(#{'@ns' := <<"tasks">>, '@id' := TaskUrn, id := TaskId, module := Module, params := TaskParams}) -> + Schema0 = schemas(<<"request">>, <<"1.0">>), + Schema = key_value:set([properties, params], TaskParams, Schema0), + {ok, Schema}. + +as_tuple(#{id := Id, task_urn := TaskUrn, node := Node, params := Params}) -> + #?MODULE{id = Id, version = undefined, schema = undefined, task_urn = TaskUrn, + node = Node, params = Params}. + +as_map(R = #?MODULE{}) -> + #{ + id => R#?MODULE.id, + version => R#?MODULE.version, + schema => R#?MODULE.schema, + task_urn => R#?MODULE.task_urn, + node => R#?MODULE.node, + params => R#?MODULE.params + }. + +after_create(Request) -> + ok. diff --git a/apps/dreki/src/runs/dreki_runs.erl b/apps/dreki/src/runs/dreki_runs.erl new file mode 100644 index 0000000..9509fa6 --- /dev/null +++ b/apps/dreki/src/runs/dreki_runs.erl @@ -0,0 +1 @@ +-module(dreki_runs). diff --git a/apps/dreki/src/storages/dreki_storages.erl b/apps/dreki/src/storages/dreki_storages.erl new file mode 100644 index 0000000..3a87347 --- /dev/null +++ b/apps/dreki/src/storages/dreki_storages.erl @@ -0,0 +1,98 @@ +-module(dreki_storages). +-behaviour(dreki_store_namespace). +-export([start/0, version/0, valid_store/4, format_item/1, actions/0, actions/1, schemas/0, as_tuple/1, as_map/1]). +-export([record_name/0, record_attributes/0]). +-export([after_create/1]). +-export([install_local_store/0, create_local_storage/4]). + +-record(?MODULE, { + id, + version, + schema, + name, + type, + handler, + params, + tags = [] + }). + +install_local_store() -> + NodeUrn = dreki_node:urn(), + Urn = <<NodeUrn/binary, "::storages:local">>, + dreki_store:create_store(Urn, dreki_mnesia_store, #{}, #{}). + +create_local_storage(Type, Handler, Params, Tags) -> + NodeUrn = dreki_node:urn(), + LocalStoreUrn = <<NodeUrn/binary, "::storages:local">>, + Storage = #{ + type => Type, + handler => Handler, + params => Params, + tags => Tags + }, + dreki_store:create(LocalStoreUrn, Storage). + +record_name() -> ?MODULE. +record_attributes() -> record_info(fields, ?MODULE). +version() -> 1. + +start() -> + ok. + +valid_store(_Ns, _Loc, _Name, _BackendMod) -> + ok. + +format_item(Item) -> + ok. + +actions() -> + []. + +actions(_) -> + []. + +schemas() -> + #{default => <<"storage">>, + <<"storage">> => #{ + default_version => <<"1.0">>, + <<"1.0">> => schemas(<<"storage">>, <<"1.0">>) + } + }. + +schemas(<<"storage">>, <<"1.0">>) -> + #{ + version => 'draft-06', + title => <<"Storage">>, + type => object, + required => [id, type, handler, params], + properties => #{ + id => #{type => string, <<"dreki:form">> => #{}}, + name => #{type => string}, + type => #{type => string, enum => [<<"fs">>, <<"os">>, <<"bs">>]}, + handler => #{type => string, enum => [<<"zfs">>, <<"fs">>]}, + params => #{type => object}, + tags => #{type => array, items => #{type => string}} + } + }. + +as_tuple(Map = #{id := Id, type := Type, handler := Handler, params := Params, tags := Tags}) -> + #?MODULE{id = Id, + name = maps:get(name, Map, undefined), + type = Type, + handler = Handler, + params = Params, + tags = maps:get(tags, Map, []) + }. + +as_map(R = #?MODULE{}) -> + #{ + id => R#?MODULE.id, + name => R#?MODULE.name, + type => R#?MODULE.type, + handler => R#?MODULE.handler, + params => R#?MODULE.params, + tags => R#?MODULE.tags + }. + +after_create(_Storage) -> + ok. diff --git a/apps/dreki/src/storages/dreki_storages_zfs.erl b/apps/dreki/src/storages/dreki_storages_zfs.erl new file mode 100644 index 0000000..fc8ce00 --- /dev/null +++ b/apps/dreki/src/storages/dreki_storages_zfs.erl @@ -0,0 +1,2 @@ +-module(dreki_storages_zfs). + diff --git a/apps/dreki/src/dreki_dets_store.erl b/apps/dreki/src/store/dreki_dets_store.erl index 05cf9fb..aaa4c23 100644 --- a/apps/dreki/src/dreki_dets_store.erl +++ b/apps/dreki/src/store/dreki_dets_store.erl @@ -4,7 +4,7 @@ -behaviour(dreki_store_backend). --export([start/0, start/4, checkout/1, checkin/1, stop/0, stop/1]). +-export([start/0, start/5, checkout/1, checkin/1, stop/0, stop/1]). -export([valid_store/5]). -export([list/1, count/1, exists/2, get/2, create/2, update/2, delete/2]). @@ -14,7 +14,7 @@ start() -> ok. valid_store(_Namespace, _Location, _Name, _NSMod, _Args) -> ok. -start(Namespace, Name, _XUrn, Args) -> +start(Namespace, _NsMod, Name, _XUrn, Args) -> StoreName = {?MODULE, Namespace, Name}, File = maps:get(file_name, Args, <<Namespace/binary, ".", Name/binary, ".dets">>), FileName = binary:bin_to_list(File), @@ -71,4 +71,3 @@ update({dreki_dets_store_ref, Tab}, Task = #dreki_task{persisted=true, dirty=tru ok -> {ok, Task#dreki_task{dirty=false}}; {error, Error} -> {error, Error} end. - diff --git a/apps/dreki/src/store/dreki_mnesia_store.erl b/apps/dreki/src/store/dreki_mnesia_store.erl new file mode 100644 index 0000000..aceae09 --- /dev/null +++ b/apps/dreki/src/store/dreki_mnesia_store.erl @@ -0,0 +1,94 @@ +-module(dreki_mnesia_store). +-include("dreki.hrl"). +-include("dreki_otel.hrl"). +-include_lib("stdlib/include/ms_transform.hrl"). +-include_lib("kernel/include/logger.hrl"). +-behaviour(dreki_store_backend). + +-export([start/0, start/5, checkout/1, checkin/1, stop/0, stop/1]). +-export([valid_store/5]). +-export([list/1, count/1, exists/2, get/2, create/2, update/2, delete/2]). + +start() -> + ok. + +valid_store(_NS, _Loc, _Name, _NSMod, _Args) -> + ok. + +start(Namespace, NsMod, Name, _XUrn, Args) -> + TabName = binary_to_atom(iolist_to_binary(["dreki_mnesia_store", "-", Namespace, "-", Name])), + case lists:member(TabName, mnesia:system_info(tables)) of + true -> + {ok, TabName}; + false -> + DefaultOptions = [{rocksdb_copies, [node()]}], + Options0 = maps:get(mnesia_table_options, Args, DefaultOptions), + Options = [{attributes, NsMod:record_attributes()}, {record_name, NsMod:record_name()} | Options0], + {atomic, ok} = mnesia:create_table(TabName, Options), + ?LOG_NOTICE(#{message => "Created mnesia table for store", mnesia_table => TabName, store_namespace => Namespace, store_name => Name}), + {ok, TabName} + end. + +checkout(TabName) -> + {ok, TabName}. + +checkin(_) -> + ok. + +stop() -> + ok. + +stop(_) -> + ok. + +list(TabName) -> + transaction(fun () -> + [get(TabName, Id) || Id <- iter_all(mnesia:first(TabName), TabName)] + end). + +count(TabName) -> + mnesia:table_info(TabName, size). + +get(TabName, Id) -> + transaction(fun() -> + case mnesia:read(TabName, Id) of + [Tuple] -> + Tuple; + [] -> + not_found + end + end). + +exists(TabName, Id) -> + case get(TabName, Id) of + not_found -> + false; + _ -> + true + end. + +create(Table, Tuple) -> + transaction(fun() -> + mnesia:write(Table, Tuple, write), + get(Table, element(2, Tuple)) + end). + +update(Table, Tuple) -> + create(Table, Tuple). + +delete(TabName, Id) -> + transaction(fun() -> + mnesia:delete(TabName, Id, write) + end). + +transaction(Fun) -> + ?with_span(?FUN_NAME, #{}, fun(_) -> {atomic, Result} = mnesia:transaction(Fun), Result end). + +iter_all(Id, TabName) -> + ?with_span(?FUN_NAME, #{}, fun(_) -> iter_all(Id, TabName, []) end). + +iter_all('$end_of_table', _, Acc) -> + Acc; +iter_all(Id, TabName, Acc) -> + [Tuple] = mnesia:read(TabName, Id), + iter_all(mnesia:next(TabName, Id), TabName, [Tuple | Acc]). diff --git a/apps/dreki/src/dreki_store.erl b/apps/dreki/src/store/dreki_store.erl index ae6fd66..63d87a8 100644 --- a/apps/dreki/src/dreki_store.erl +++ b/apps/dreki/src/store/dreki_store.erl @@ -1,7 +1,7 @@ -module(dreki_store). -include("dreki.hrl"). -include("dreki_plum.hrl"). --include_lib("opentelemetry_api/include/otel_tracer.hrl"). +-include("dreki_otel.hrl"). -define(BACKENDS_PT, {dreki_stores, backends}). -compile({no_auto_import,[get/1]}). -export([backends/0]). @@ -16,13 +16,20 @@ -export([callback/3, list_/2, get_/2, new_/2, create_/3, update_/3, delete_/2]). --record(store, {urn, xurn, namespace, name, namespace_mod, backend_mod, backend_params}). +-record(store, {urn, xurn, namespace, name, namespace_mod, format, backend_mod, backend_params}). -type t() :: #store{}. -type store() :: t() | dreki_uri() | dreki_expanded_uri(). -backends() -> [dreki_dets_store, dreki_world_store]. -namespaces() -> [{<<"tasks">>, dreki_tasks, #{}}]. +backends() -> + [dreki_dets_store, dreki_world_store]. + +namespaces() -> + [ + {<<"tasks">>, dreki_tasks, #{}}, + {<<"requests">>, dreki_requests, #{}}, + {<<"storages">>, dreki_storages, #{}} + ]. namespace(Name) -> lists:keyfind(Name, 1, namespaces()). @@ -80,7 +87,7 @@ start_store__(Store) -> _Started -> {error, {store_already_started, Store#store.urn}} end. start_store___(Store = #store{backend_mod = Mod}) -> - case Mod:start(Store#store.namespace, Store#store.name, Store#store.urn, Store#store.backend_params) of + case Mod:start(Store#store.namespace, Store#store.namespace_mod, Store#store.name, Store#store.urn, Store#store.backend_params) of {ok, Backend} -> {ok, Backend}; {error, Error} -> {error, {store_start_failed, Store#store.urn, Error}} end. @@ -163,10 +170,11 @@ get_store(Location, Namespace, Name) -> end. as_record([Map]) -> as_record(Map); -as_record(#{urn := Urn, name := Name, namespace := Namespace, module := Module, module_params := ModuleParams}) -> +as_record(Store = #{urn := Urn, name := Name, namespace := Namespace, module := Module, module_params := ModuleParams}) -> {ok, XUrn} = dreki_urn:expand(Urn), {_, NSMod, _} = lists:keyfind(Namespace, 1, namespaces()), - #store{urn = Urn, xurn = XUrn, name = Name, namespace = Namespace, namespace_mod = NSMod, backend_mod = Module, backend_params = ModuleParams}. + Format = maps:get(format, Store, tuple), + #store{urn = Urn, xurn = XUrn, name = Name, namespace = Namespace, namespace_mod = NSMod, format = Format, backend_mod = Module, backend_params = ModuleParams}. store_as_map(#store{urn = Urn, name = Name, namespace = Namespace, namespace_mod = NSMod, backend_mod = Module, backend_params = ModuleParams}) -> #{urn => Urn, name => Name, namespace => Namespace, namespace_mod => NSMod, backend_mod => Module, backend_params => ModuleParams}. @@ -191,14 +199,14 @@ delete(SArg) -> get_store_(SArg, delete_, []). list_(#{resource := #{directory := _}}, Store) -> - handle_result(Store, collection, callback(Store, list, [])); + handle_result(Store, collection, callback(Store, list, []), list); list_(#{schema := _}, Store) -> {todo, schema}; list_(_, _) -> not_supported. get_(#{resource := #{resource := #{id := Id}}}, Store) -> - handle_result(Store, single, callback(Store, get, [Id])); + handle_result(Store, single, callback(Store, get, [Id]), get); get_(#{schema := _}, Store) -> {todo, get_schema}; get_(_, _) -> not_supported. @@ -208,21 +216,36 @@ new_(#{resource := #{directory := _}}, Store) -> New = NSMod:new(), {ok, New}. -create_(XUrn = #{directory := _}, Store, Data) -> - case validate(XUrn, Data) of - {ok, _} -> - handle_result(Store, single, callback(Store, create, [Data])); - {error, Errors} -> {error, #{code => validation_failed, status => 422, errors => jesse_error:to_json({error, Errors}, [])}} - end; +create_(XUrn = #{resource := #{directory := _}}, Store, Data0) -> + Id = maps:get(id, Data0, ksuid:gen_id()), + Data1 = Data0#{id => Id}, + case validate(XUrn, Data1) of + {ok, _} -> + Data = case Store#store.format of + tuple -> + Mod = Store#store.namespace_mod, + Mod:as_tuple(Data1); + map -> Data1 + end, + handle_result(Store, single, callback(Store, create, [Data]), create); + {error, Errors} -> + {error, #{code => validation_failed, status => 422, errors => jesse_error:to_json({error, Errors}, [])}} + end; create_(_, _, _) -> - not_supported. + not_supported. -update_(XUrn = #{resource := _}, Store, Data) -> +update_(XUrn = #{resource := #{resource := _}}, Store, Data0) -> case get(XUrn) of {ok, Prev} -> - case validate(XUrn, Data) of + case validate(XUrn, Data0) of {ok, _} -> - handle_result(Store, single, callback(Store, create, [Data])); + Data = case Store#store.format of + tuple -> + Mod = Store#store.namespace_mod, + Mod:as_tuple(Data0); + map -> Data0 + end, + handle_result(Store, single, callback(Store, update, [XUrn, Data]), update); {error, Errors} -> {error, #{code => validation_failed, status => 422, errors => jesse_error:to_json({error, Errors}, [])}} end; @@ -240,21 +263,27 @@ get_store_(StoreArg, Fun, Args) when is_binary(StoreArg) -> logger:debug("Expanding in get_store_ :) ~p", [StoreArg]), case dreki_urn:expand(StoreArg) of Error = {error, _} -> Error; - {ok, XUrn = #{resource := #{schemas := _}}} -> get_schema_(XUrn, Fun, Args); - {ok, XUrn = #{resource := #{schema := _}}} -> get_schema_(XUrn, Fun, Args); {ok, XUrn} -> get_store_(XUrn, Fun, Args) end; +get_store_(XUrn = #{resource := #{shemas := _}}, Fun, Args) -> + get_schema_(XUrn, Fun, Args); +get_store_(XUrn = #{resource := #{schema := _}}, Fun, Args) -> + get_schema_(XUrn, Fun, Args); get_store_(XUrn = #{resource := _}, Fun, Args) -> - ?with_span(<<"dreki_store:get_store_">>, #{}, fun(_Ctx) -> - logger:debug("Getting store usually! ~p", [XUrn]), - case get_store(XUrn) of + %%T = otel_tracer:start_span(opentelemetry:get_tracer(), <<"dreki_store">>, #{}), + %%?set_current_span(T), + ?with_span(opentelemetry:get_tracer(), #{}, fun(SpanCtx) -> + logger:debug("Getting store usually! ~p / Ctx: ~p / Span Ctx ~p / Cur Span Ctx ~p", [XUrn, otel_ctx:get_current(), SpanCtx, otel_tracer:current_span_ctx()]), + Result = case get_store(XUrn) of Error = {error, _} -> Error; {ok, Store} -> case apply(?MODULE, Fun, [XUrn, Store | Args]) of not_supported -> {error, {not_supported, Fun, maps:get(urn, XUrn)}}; Out -> Out end - end + end, + ?end_span(), + Result end). is_local(#store{xurn = #{kind := region}}) -> true; @@ -279,38 +308,50 @@ callback(true, #store{urn = Urn, backend_mod = Mod, backend_params = Params}, Fu Res end. -handle_result(_, _, {error, Err}) -> {error, Err}; -handle_result(Store, collection, {ok, Collection}) when is_list(Collection) -> - handle_result(Store, collection, Collection); -handle_result(Store, collection, Collection) when is_list(Collection) -> - {ok, handle_collection_result(Collection, Store, [])}; -handle_result(Store, single, {ok, Item}) when is_map(Item) -> - {ok, handle_single_result(Item, Store)}. - -handle_collection_result([Item | Rest], Store, Acc0) -> - Acc = case handle_single_result(Item, Store) of +handle_result(_, _, {error, Err}, _) -> {error, Err}; +handle_result(Store, collection, {ok, Collection}, PostCallback) when is_list(Collection) -> + handle_result(Store, collection, Collection, PostCallback); +handle_result(Store, collection, Collection, PostCallback) when is_list(Collection) -> + {ok, handle_collection_result(Collection, Store, [], PostCallback)}; +handle_result(Store, single, Item, PostCallback) -> + handle_single_result(Item, Store, PostCallback); +handle_result(Store, single, {ok, Item}, PostCallback)-> + handle_single_result(Item, Store, PostCallback). + +handle_collection_result([Item | Rest], Store, Acc0, PostCallback) -> + Acc = case handle_single_result(Item, Store, PostCallback) of ignore -> Acc0; {ok, I} -> [I | Acc0] end, - handle_collection_result(Rest, Store, Acc); -handle_collection_result([], Store, Acc) -> + handle_collection_result(Rest, Store, Acc, PostCallback); +handle_collection_result([], Store, Acc, _PostCallback) -> format_collection(Acc, Store). -handle_single_result(Item = #{id := LId}, Store = #store{namespace_mod = Mod}) -> +handle_single_result(Tuple, Store = #store{format = tuple, namespace_mod = Mod}, PostCallback) when is_tuple(Tuple) -> + handle_single_result(Mod:as_map(Tuple), Store, PostCallback); +handle_single_result(Item = #{id := LId}, Store = #store{namespace_mod = Mod}, PostCallback) -> case Mod:format_item(Item) of - ok -> format_item(Item, Store); - {ok, M} -> format_item(M, Store); + ok -> format_item(Item, Item, Store, PostCallback); + {ok, M} -> format_item(M, Item, Store, PostCallback); Err -> Err end. -format_item(Item = #{id := LId}, Store = #store{urn = Urn}) -> +format_item(Item = #{id := LId}, OriginalItem, Store = #store{urn = Urn, namespace = NS, namespace_mod = Mod}, PostCallback) -> + {ok, XUrn} = dreki_urn:expand(Urn), + SelfUrn = <<Urn/binary, ":", LId/binary>>, AtLinks = #{ - self => <<Urn/binary, ":", LId>>, - parent => maps:get(location, Urn) + self => SelfUrn, + parent => Urn }, AllAtLinks = maps:merge(AtLinks, maps:get('@links', Item, #{})), - I = #{'@id' => <<Urn/binary, ":", LId>>, '@links' => AllAtLinks}, - {ok, maps:merge(I, Item)}. + Actions = [#{id => Id, title => Title, new => New, create => Create} || {Id, Title, New, Create} <- Mod:actions(OriginalItem)], + I = #{'@id' => SelfUrn, '@ns' => NS, '@location' => maps:get(location, XUrn), '@links' => AllAtLinks, '@actions' => Actions}, + FullItem = maps:merge(I, Item), + case PostCallback of + create -> Mod:after_create(FullItem); + _ -> undefined + end, + {ok, FullItem}. format_collection(Data, Store = #store{urn = Urn}) -> #{'@links' => #{self => Urn}, @@ -362,11 +403,12 @@ get_schema_(#{urn := Urn, resource := #{namespace := NS, schema := #{schema := d case maps:get(default_version, Schema, not_found) of not_found -> not_found; SchemaVer -> - NewUrn = binary:replace(Urn, <<"schemas::">>, <<"schemas:", SchemaName/binary, ":", SchemaVer/binary>>), + NewUrn0 = binary:replace(Urn, <<"::schemas::">>, <<>>), + NewUrn = <<NewUrn0/binary, "::schemas:", SchemaName/binary, ":", SchemaVer/binary>>, {ok, XUrn} = dreki_urn:expand(NewUrn), - logger:debug("Looking up default schema as ~p", [NewUrn, XUrn]), + logger:debug("Looking up default schema as ~p (~p)", [NewUrn, XUrn]), get_schema_(XUrn, get_, Args) - end + end end; get_schema_(XUrn = #{resource := #{namespace := NS, schema := #{schema := SchemaName, version := Version}}}, get_, _) -> {_, NSMod, _} = namespace(NS), @@ -423,7 +465,11 @@ get_xurn_namespace(#{resource := #{namespace := NS}}) -> NS; get_xurn_namespace(#{resource := #{directory := #{namespace := NS}}}) -> NS; get_xurn_namespace(#{resource := #{resource := #{namespace := NS}}}) -> NS. +schema_loader(SchemaRef) -> + logger:debug("Trying to load schema ~p", [SchemaRef]), + get(SchemaRef). + validate_(XUrn, Schema, Data) -> JesseOpts = [{allowed_errors, infinity}, - {schema_loader_fun, fun get/1}], + {schema_loader_fun, fun schema_loader/1}], jesse:validate_with_schema(Schema, Data, JesseOpts). diff --git a/apps/dreki/src/dreki_store_backend.erl b/apps/dreki/src/store/dreki_store_backend.erl index 55db90e..55db90e 100644 --- a/apps/dreki/src/dreki_store_backend.erl +++ b/apps/dreki/src/store/dreki_store_backend.erl diff --git a/apps/dreki/src/dreki_store_namespace.erl b/apps/dreki/src/store/dreki_store_namespace.erl index 3095ef7..3095ef7 100644 --- a/apps/dreki/src/dreki_store_namespace.erl +++ b/apps/dreki/src/store/dreki_store_namespace.erl diff --git a/apps/dreki/src/dreki_task.erl b/apps/dreki/src/tasks/dreki_task.erl index b762aea..b762aea 100644 --- a/apps/dreki/src/dreki_task.erl +++ b/apps/dreki/src/tasks/dreki_task.erl diff --git a/apps/dreki/src/dreki_tasks.erl b/apps/dreki/src/tasks/dreki_tasks.erl index 0899386..750aed0 100644 --- a/apps/dreki/src/dreki_tasks.erl +++ b/apps/dreki/src/tasks/dreki_tasks.erl @@ -2,7 +2,7 @@ -include("dreki.hrl"). -behaviour(dreki_store_namespace). --export([start/0, version/0, valid_store/4, format_item/1, schemas/0, new/0]). +-export([start/0, version/0, valid_store/4, format_item/1, actions/0, actions/1, schemas/0, new/0, as_tuple/1, as_map/1]). %% old stuff -export([resolve/1, exists/1, read_uri/2]). @@ -13,14 +13,14 @@ valid_store(_Namespace, _Location, _Name, _BackendMod) -> ok. format_item(Item) -> ok. -handlers() -> [dreki_tasks_script, dreki_tasks_cloyster]. +handlers() -> [dreki_tasks_script, dreki_tasks_cloyster, drekid_function]. --record(?MODULE, { +-record(t, { id, version, schema, - handler, - handler_manifest + module, + params }). version() -> 1. @@ -29,13 +29,25 @@ new() -> #{ <<"@schema">> => <<"task:1.0">>, <<"id">> => ksuid:gen_id(), - <<"handler">> => <<"dreki_tasks_cloyster">>, - <<"handler_manifest">> => #{ + <<"module">> => <<"dreki_tasks_cloyster">>, + <<"params">> => #{ <<"@schema">> => <<"cloyster-task:1.0">>, <<"script">> => <<>> } }. +actions() -> + [ + {new, <<"New task">>, {dreki_tasks, new, []}, {dreki_store, create, []}} + ]. + +actions(_) -> + [ + {request, <<"Request run">>, + {dreki_requests, new, []}, + {dreki_store, create, []}} + ]. + schemas() -> Subs = lists:foldr(fun (Handler, Acc) -> maps:merge(Acc, Handler:schemas()) end, #{}, handlers()), @@ -80,6 +92,19 @@ schemas(task, <<"1.0">>) -> required => [handler, handler_manifest] }. +as_tuple(#{id := Id, module := Module, params := Params}) -> + #t{id = Id, version = undefined, schema = undefined, + module = Module, params = Params}. + +as_map(Task = #t{}) -> + #{ + id => Task#t.id, + version => Task#t.version, + schema => Task#t.schema, + module => Task#t.module, + params => Task#t.params + }. + %% old stuff read_uri(undefined, Uri) -> @@ -136,6 +161,6 @@ load_local_stores() -> env => Env, name => Name }, - maps:put(Name, Store, Acc) + maps:put(Name, Store, Acc) end, lists:foldr(MapFn, #{}, Val). diff --git a/apps/dreki/src/dreki_tasks_cloyster.erl b/apps/dreki/src/tasks/dreki_tasks_cloyster.erl index 3fb045d..3fb045d 100644 --- a/apps/dreki/src/dreki_tasks_cloyster.erl +++ b/apps/dreki/src/tasks/dreki_tasks_cloyster.erl diff --git a/apps/dreki/src/dreki_tasks_script.erl b/apps/dreki/src/tasks/dreki_tasks_script.erl index 8eeb563..8eeb563 100644 --- a/apps/dreki/src/dreki_tasks_script.erl +++ b/apps/dreki/src/tasks/dreki_tasks_script.erl diff --git a/apps/dreki/src/dreki_world.erl b/apps/dreki/src/world/dreki_world.erl index 437b6c8..437b6c8 100644 --- a/apps/dreki/src/dreki_world.erl +++ b/apps/dreki/src/world/dreki_world.erl diff --git a/apps/dreki/src/dreki_world_dns.erl b/apps/dreki/src/world/dreki_world_dns.erl index 058c9db..058c9db 100644 --- a/apps/dreki/src/dreki_world_dns.erl +++ b/apps/dreki/src/world/dreki_world_dns.erl diff --git a/apps/dreki/src/dreki_world_plum_events.erl b/apps/dreki/src/world/dreki_world_plum_events.erl index b32ae09..b32ae09 100644 --- a/apps/dreki/src/dreki_world_plum_events.erl +++ b/apps/dreki/src/world/dreki_world_plum_events.erl diff --git a/apps/dreki/src/dreki_world_server.erl b/apps/dreki/src/world/dreki_world_server.erl index 2bc41ed..2bc41ed 100644 --- a/apps/dreki/src/dreki_world_server.erl +++ b/apps/dreki/src/world/dreki_world_server.erl diff --git a/apps/dreki/src/dreki_world_store.erl b/apps/dreki/src/world/dreki_world_store.erl index 71ef2ce..050efd0 100644 --- a/apps/dreki/src/dreki_world_store.erl +++ b/apps/dreki/src/world/dreki_world_store.erl @@ -8,7 +8,7 @@ -type db() :: #store{}. -type args() :: #{}. --export([start/0, start/4, checkout/1, checkin/1, stop/0, stop/1]). +-export([start/0, start/5, checkout/1, checkin/1, stop/0, stop/1]). -export([valid_store/5]). -export([list/1, count/1, exists/2, get/2, create/2, update/2, delete/2]). @@ -19,7 +19,7 @@ valid_store(_Namespace, Location, _Name, _NSMod, _Args) -> end. start() -> ok. -start(_, _, _, _) -> {ok, dreki_world_store}. +start(_, _, _, _, _) -> {ok, dreki_world_store}. checkout(_) -> {ok, #store{}}. checkin(_) -> ok. diff --git a/apps/dreki/src/dreki_world_tasks.erl b/apps/dreki/src/world/dreki_world_tasks.erl index c27a7dc..c27a7dc 100644 --- a/apps/dreki/src/dreki_world_tasks.erl +++ b/apps/dreki/src/world/dreki_world_tasks.erl diff --git a/apps/dreki_web/.gitignore b/apps/dreki_web/.gitignore index 8dec94f..8946870 100644 --- a/apps/dreki_web/.gitignore +++ b/apps/dreki_web/.gitignore @@ -18,3 +18,4 @@ _build rebar3.crashdump *~ /priv/static/ +/assets/node_modules/ diff --git a/apps/dreki_web/assets/package-lock.json b/apps/dreki_web/assets/package-lock.json index c99c8b3..b6aa028 100644 --- a/apps/dreki_web/assets/package-lock.json +++ b/apps/dreki_web/assets/package-lock.json @@ -12,7 +12,8 @@ "@tailwindcss/forms": "^0.5.0", "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.2", - "crossfilter": "^1.3.12" + "crossfilter": "^1.3.12", + "xterm": "^4.18.0" }, "devDependencies": { "@hotwired/stimulus": "^3.0.1", @@ -2063,6 +2064,11 @@ "node": ">=0.4" } }, + "node_modules/xterm": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.18.0.tgz", + "integrity": "sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3567,6 +3573,11 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "xterm": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.18.0.tgz", + "integrity": "sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==" + }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/apps/dreki_web/assets/package.json b/apps/dreki_web/assets/package.json index 07072c5..1aa07ff 100644 --- a/apps/dreki_web/assets/package.json +++ b/apps/dreki_web/assets/package.json @@ -27,6 +27,7 @@ "@tailwindcss/forms": "^0.5.0", "@tailwindcss/line-clamp": "^0.3.1", "@tailwindcss/typography": "^0.5.2", - "crossfilter": "^1.3.12" + "crossfilter": "^1.3.12", + "xterm": "^4.18.0" } } diff --git a/apps/dreki_web/rebar.config b/apps/dreki_web/rebar.config index b16060a..e439af6 100644 --- a/apps/dreki_web/rebar.config +++ b/apps/dreki_web/rebar.config @@ -5,7 +5,8 @@ {erlydtl, "0.14.0"}, {oauth2c, {git, "https://github.com/kivra/oauth2_client", {branch, "master"}}}, {cowboy_telemetry, "~> 0.4.0"}, - {opentelemetry_cowboy, "~> 0.1.0"} + {opentelemetry_cowboy, "~> 0.1.0"}, + {prometheus_cowboy, "0.1.8"} ]}. {plugins, [ diff --git a/apps/dreki_web/src/dreki_web_admin_tasks.erl b/apps/dreki_web/src/admin/dreki_web_admin_tasks.erl index adc2689..adc2689 100644 --- a/apps/dreki_web/src/dreki_web_admin_tasks.erl +++ b/apps/dreki_web/src/admin/dreki_web_admin_tasks.erl diff --git a/apps/dreki_web/src/dreki_web_admin_world.erl b/apps/dreki_web/src/admin/dreki_web_admin_world.erl index 1ceaaee..1ceaaee 100644 --- a/apps/dreki_web/src/dreki_web_admin_world.erl +++ b/apps/dreki_web/src/admin/dreki_web_admin_world.erl diff --git a/apps/dreki_web/src/dreki_web_index.erl b/apps/dreki_web/src/api/dreki_web_index.erl index 2ed8a38..2ed8a38 100644 --- a/apps/dreki_web/src/dreki_web_index.erl +++ b/apps/dreki_web/src/api/dreki_web_index.erl diff --git a/apps/dreki_web/src/dreki_web_task.erl b/apps/dreki_web/src/api/dreki_web_task.erl index fdcf9cd..fdcf9cd 100644 --- a/apps/dreki_web/src/dreki_web_task.erl +++ b/apps/dreki_web/src/api/dreki_web_task.erl diff --git a/apps/dreki_web/src/cowboy_access_log_h.erl b/apps/dreki_web/src/cowboy_access_log_h.erl new file mode 100644 index 0000000..433d706 --- /dev/null +++ b/apps/dreki_web/src/cowboy_access_log_h.erl @@ -0,0 +1,238 @@ +-module(cowboy_access_log_h). +-behaviour(cowboy_stream). + +-dialyzer(no_undefined_callbacks). + +-type extra_info_fun() :: fun((cowboy_req:req()) -> #{atom() => term()}). +-export_type([extra_info_fun/0]). + +%% API exports + +-export([set_extra_info_fun/2]). + +%% callback exports + +-export([init/3]). +-export([data/4]). +-export([info/3]). +-export([terminate/3]). +-export([early_error/5]). + +-type state() :: #{ + next := any(), + req := cowboy_req:req(), + meta := #{started_at => genlib_time:ts()}, + ext_fun := extra_info_fun() +}. + +%% API + +-spec set_extra_info_fun(extra_info_fun(), cowboy:opts()) + -> cowboy:opts(). +set_extra_info_fun(Fun, Opts) when is_function(Fun, 1) -> + Opts#{extra_info_fun => Fun}. + +%% callbacks + +-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts()) + -> {cowboy_stream:commands(), state()}. +init(StreamID, Req, Opts) -> + State = make_state(Req, Opts), + {Commands0, Next} = cowboy_stream:init(StreamID, Req, Opts), + {Commands0, State#{next => Next}}. + +-spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State) + -> {cowboy_stream:commands(), State} when State::state(). +data(StreamID, IsFin, Data, #{next := Next0} = State) -> + {Commands0, Next} = cowboy_stream:data(StreamID, IsFin, Data, Next0), + {Commands0, State#{next => Next}}. + +-spec info(cowboy_stream:streamid(), any(), State) + -> {cowboy_stream:commands(), State} when State::state(). +info(StreamID, {IsResponse, Code, Headers, _} = Info, #{req := Req, next := Next0} = State) when + IsResponse == response; + IsResponse == error_response +-> + _ = log_access_safe(Code, Headers, State, get_request_body_length(Req)), + {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0), + {Commands0, State#{next => Next}}; +info(StreamID, Info, #{next := Next0} = State) -> + {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0), + {Commands0, State#{next => Next}}. + +-spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), state()) -> any(). +terminate(StreamID, Reason, #{next := Next}) -> + cowboy_stream:terminate(StreamID, Reason, Next). + +-spec early_error(cowboy_stream:streamid(), cowboy_stream:reason(), + cowboy_stream:partial_req(), Resp, cowboy:opts()) -> Resp + when Resp::cowboy_stream:resp_command(). + +%% NOTE: in early_error cowboy uses PartialReq, a cowboy_req:req() - like structure +%% for more info see https://ninenines.eu/docs/en/cowboy/2.7/manual/cowboy_stream/#_callbacks + +early_error(StreamID, Reason, PartialReq, {_, Code, Headers, _} = Resp, Opts) -> + State = make_state(PartialReq, Opts), + _ = log_access_safe(Code, Headers, State, undefined), + cowboy_stream:early_error(StreamID, Reason, PartialReq, Resp, State). + +%% private functions + +log_access_safe(Code, Headers, #{req := Req} = State, ReqBodyLength) -> + try + logger:log(info, prepare_meta(Code, Headers, State, ReqBodyLength)), + Req + catch + Class:Reason:Stacktrace -> + Stack = genlib_format:format_stacktrace(Stacktrace, [newlines]), + _ = logger:error( + "Log access failed for: [~p, ~p, ~p]~nwith: ~p:~p~nstacktrace: ~ts", + [Code, Headers, Req, Class, Reason, Stack] + ), + Req + end. + +get_process_meta() -> + case logger:get_process_metadata() of + undefined -> + #{}; + Meta -> + Meta + end. + +% domain field specifies the functional area that send log event +% as we want to save logs from this app in a separate file, +% we can easily filter logs by their domain using OTP filter functions. +prepare_meta(Code, Headers, #{req := Req, meta:= Meta0, ext_fun := F}, ReqBodyLength) -> + AccessMeta = genlib_map:compact(#{ + domain => [cowboy_access_log], + status => Code, + remote_addr => get_remote_addr(Req), + peer_addr => get_peer_addr(Req), + request_method => cowboy_req:method(Req), + request_path => cowboy_req:path(Req), + request_length => ReqBodyLength, + response_length => get_response_len(Headers), + request_time => get_request_duration(Meta0), + trace_id => maps:get(<<"trace-id">>, Req, <<"no-trace">>), + 'http_x-request-id' => cowboy_req:header(<<"x-request-id">>, Req, undefined) + }), + AccessMeta1 = maps:merge(get_process_meta(), AccessMeta), + maps:merge(F(Req), AccessMeta1). + +get_request_body_length(Req) -> + case cowboy_req:has_body(Req) of + false -> undefined; + true -> cowboy_req:body_length(Req) + end. + +get_peer_addr(Req) -> + {IP, _Port} = cowboy_req:peer(Req), + genlib:to_binary(inet:ntoa(IP)). + +get_remote_addr(Req) -> + case determine_remote_addr(Req) of + {ok, RemoteAddr} -> + genlib:to_binary(inet:ntoa(RemoteAddr)); + _ -> + undefined + end. + +determine_remote_addr(Req) -> + Peer = cowboy_req:peer(Req), + Value = cowboy_req:header(<<"x-forwarded-for">>, Req), + determine_remote_addr_from_header(Value, Peer). + +determine_remote_addr_from_header(undefined, {IP, _Port}) -> + % undefined, assuming no proxies were involved + {ok, IP}; +determine_remote_addr_from_header(Value, _Peer) when is_binary(Value) -> + ClientPeer = string:strip(binary_to_list(Value)), + case string:tokens(ClientPeer, ", ") of + [ClientIP | _Proxies] -> + inet:parse_strict_address(ClientIP); + _ -> + {error, malformed} + end. + +get_request_duration(Meta) -> + case maps:get(started_at, Meta, undefined) of + undefined -> + undefined; + StartTime -> + (genlib_time:ticks() - StartTime) / 1000000 + end. + +get_response_len(Headers) -> + case maps:get(<<"content-length">>, Headers, undefined) of + undefined -> + undefined; + Len -> + genlib:to_int(Len) + end. + +make_state(Req, Opts) -> + ExtFun = make_ext_fun(Opts), + set_meta(#{req => Req, ext_fun => ExtFun}). + +set_meta(State) -> + State#{meta => #{started_at => genlib_time:ticks()}}. + +make_ext_fun(Opts) -> + maps:get(extra_info_fun, Opts, fun(_Req) -> #{} end). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +-spec test() -> _. + +-spec filter_meta_test() -> _. +filter_meta_test() -> + Req = #{ + pid => self(), + peer => {{42, 42, 42, 42}, 4242}, + method => <<"GET">>, + path => <<>>, + qs => <<>>, + version => 'HTTP/1.1', + headers => #{}, + host => <<>>, + port => undefined, + has_body => true + }, + State = make_state(Req, #{}), + #{ + request_method := <<"GET">>, + request_path := <<>>, + request_time := _, + response_length := 33, + request_length := 100, + peer_addr := <<"42.42.42.42">>, + status := 200 + } = prepare_meta(200, #{<<"content-length">> => <<"33">>}, State, 100). + +-spec filter_meta_for_error_test() -> _. +filter_meta_for_error_test() -> + Req = #{ + pid => self(), + peer => {{42, 42, 42, 42}, 4242}, + method => <<"GET">>, + path => <<>>, + qs => <<>>, + version => 'HTTP/1.1', + headers => #{}, + host => <<>>, + port => undefined, + has_body => true + }, + State = make_state(Req, #{}), + #{ + peer_addr := <<"42.42.42.42">>, + remote_addr := <<"42.42.42.42">>, + request_method := <<"GET">>, + request_path := <<>>, + request_time := _, + status := 400 + } = prepare_meta(400, #{}, State, undefined). + +-endif. diff --git a/apps/dreki_web/src/dreki_web.app.src b/apps/dreki_web/src/dreki_web.app.src index cf731fc..4122e3b 100644 --- a/apps/dreki_web/src/dreki_web.app.src +++ b/apps/dreki_web/src/dreki_web.app.src @@ -10,7 +10,8 @@ cowboy, trails, cowboy_telemetry, - opentelemetry_cowboy + opentelemetry_cowboy, + prometheus_cowboy ]}, {env,[]}, {modules, []}, diff --git a/apps/dreki_web/src/dreki_web_app.erl b/apps/dreki_web/src/dreki_web_app.erl index 5b6454e..5b3c1a0 100644 --- a/apps/dreki_web/src/dreki_web_app.erl +++ b/apps/dreki_web/src/dreki_web_app.erl @@ -13,8 +13,12 @@ start(_StartType, _StartArgs) -> Config = application:get_all_env(dreki_web), Transport = proplists:get_value(transport, Config), CowboyEnv = #{ - middlewares => [dreki_web_auth, cowboy_router, cowboy_handler], - stream_handlers => [cowboy_telemetry_h, cowboy_stream_h], + middlewares => [dreki_web_auth, cowboy_router, dreki_web_handler], + stream_handlers => [cowboy_telemetry_h, + cowboy_access_log_h, + cowboy_metrics_h, + cowboy_stream_h], + metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1, env => #{ dispatch => routes() } @@ -35,7 +39,8 @@ routes() -> Trails = [ {"/", dreki_web_index, undefined}, {"/static/[...]", cowboy_static, - {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}}, + {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}}, + {"/metrics/[:registry]", prometheus_cowboy2_handler, []}, %% API {"/api/tasks/:id", dreki_web_task, undefined}, @@ -59,6 +64,7 @@ routes() -> {"/admin/:location/:namespace/:directory", dreki_web_ui_stores, undefined}, {"/admin/:location/:namespace/:directory/_/:action", dreki_web_ui_stores, action}, {"/admin/:location/:namespace/:directory/:id", dreki_web_ui_stores, undefined}, + {"/admin/:location/:namespace/:directory/:id/_/:action", dreki_web_ui_stores, action}, {"/admin/[...]", dreki_web_ui_error, #{code => 404, status => <<"Not found">>}}, diff --git a/apps/dreki_web/src/dreki_web_handler.erl b/apps/dreki_web/src/dreki_web_handler.erl new file mode 100644 index 0000000..a30fd1d --- /dev/null +++ b/apps/dreki_web/src/dreki_web_handler.erl @@ -0,0 +1,99 @@ +%% Copyright (c) 2011-2017, Loïc Hoguin <essen@ninenines.eu> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +%% Handler middleware. +%% +%% Execute the handler given by the <em>handler</em> and <em>handler_opts</em> +%% environment values. The result of this execution is added to the +%% environment under the <em>result</em> value. +-module(dreki_web_handler). +-include_lib("opentelemetry_api/include/otel_tracer.hrl"). +-behaviour(cowboy_middleware). + +-export([execute/2]). +-export([terminate/4]). + +-callback init(Req, any()) + -> {ok | module(), Req, any()} + | {module(), Req, any(), any()} + when Req::cowboy_req:req(). + +-callback terminate(any(), map(), any()) -> ok. +-optional_callbacks([terminate/3]). + +-spec execute(Req, Env) -> {ok, Req, Env} + when Req::cowboy_req:req(), Env::cowboy_middleware:env(). +execute(Req, Env=#{handler := Handler}) -> + Headers = maps:get(headers, Req), + otel_propagator_text_map:extract(maps:to_list(Headers)), + HandlerB = atom_to_binary(Handler), + Method = maps:get(method, Req), + Attributes = [ + {'http.host', maps:get(host, Req)}, + {'http.host.port', maps:get(port, Req)}, + {'http.method', Method}, + {'http.scheme', maps:get(scheme, Req)}, + {'http.target', maps:get(path, Req)}, + {'http.user_agent', maps:get(<<"user-agent">>, Headers, <<"">>)} + ], + SpanName = iolist_to_binary([<<"HTTP ">>, Method, <<" ">>, HandlerB]), + logger:debug("SpanName: ~p", [SpanName]), + ?with_span(SpanName, #{}, fun(Ctx) -> execute_(Req, Env, Ctx) end). + +execute_(Req0, Env=#{handler := Handler, handler_opts := HandlerOpts}, Ctx) -> + TraceId = otel_span:hex_trace_id(otel_tracer:current_span_ctx()), + Req = Req0#{<<"trace-id">> => TraceId}, + try Handler:init(Req, HandlerOpts) of + {ok, Req2, State} -> + Headers0 = maps:get(headers, Req2), + Headers = otel_propagator_text_map:inject(Headers0, + fun(Headers, Key, Value) -> + maps:put(Key, Value, Headers) + end), + Req3 = maps:put(headers, Headers, Req2), + Result = terminate(normal, Req3, State, Handler), + {ok, Req3, Env#{result => Result, trace_id => TraceId}}; + {Mod, Req2, State} -> + ?add_event(<<"HTTP_UPGRADE">>, #{module => Mod}), + Mod:upgrade(Req2, Env, Handler, State); + {Mod, Req2, State, Opts} -> + ?add_event(<<"HTTP_UPGRADE">>, #{module => Mod}), + Mod:upgrade(Req2, Env, Handler, State, Opts) + catch Class:Reason:Stacktrace -> + ?set_status(error, iolist_to_binary([atom_to_binary(Class)])), + render_error(Class, Reason, Stacktrace, Handler, Req, Env, Ctx) + end. + +render_error(Class, Reason, Stacktrace, Handler, Req, Env, Ctx) -> + ReasonS = lists:flatten(io_lib:format("~p", [Reason])), + Assigns = [ + {dreki_node, node()}, + {site_title, "Dreki"}, + {class, atom_to_binary(Class)}, + {reason, ReasonS}, + {stacktrace, [lists:flatten(io_lib:format("~p", [Line])) || Line <- Stacktrace]}, + {trace_id, maps:get(<<"trace-id">>, Req, <<"no-trace">>)} + ], + logger:error(#{app => dreki_web, handler => Handler, error => {Class, Reason, Stacktrace}}), + {ok, Html} = crash_dtl:render(Assigns), + cowboy_req:reply(500, #{<<"content-type">> => <<"text/html">>}, Html, Req). + +-spec terminate(any(), Req | undefined, any(), module()) -> ok when Req::cowboy_req:req(). +terminate(Reason, Req, State, Handler) -> + case erlang:function_exported(Handler, terminate, 3) of + true -> + Handler:terminate(Reason, Req, State); + false -> + ok + end. diff --git a/apps/dreki_web/src/dreki_web_ui.erl b/apps/dreki_web/src/ui/dreki_web_ui.erl index d60cc6e..e394632 100644 --- a/apps/dreki_web/src/dreki_web_ui.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui.erl @@ -24,7 +24,8 @@ assigns(Req, Assigns0) -> {"identity", maps:get(identity, Req)}, {"identity_name", dreki_web:identity_name(Req)}, {"dreki_node", node()}, - {"dreki_world", dreki_world:to_map()} + {"dreki_world", dreki_world:to_map()}, + {"trace_id", maps:get(<<"trace-id">>, Req, <<"no-trace">>)} | Assigns]. content_types_accepted(Req, State) -> diff --git a/apps/dreki_web/src/dreki_web_ui_error.erl b/apps/dreki_web/src/ui/dreki_web_ui_error.erl index ccda150..ccda150 100644 --- a/apps/dreki_web/src/dreki_web_ui_error.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_error.erl diff --git a/apps/dreki_web/src/dreki_web_ui_index.erl b/apps/dreki_web/src/ui/dreki_web_ui_index.erl index 9f4684e..9f4684e 100644 --- a/apps/dreki_web/src/dreki_web_ui_index.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_index.erl diff --git a/apps/dreki_web/src/dreki_web_ui_json_form.erl b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl index 49f69f8..51a9de4 100644 --- a/apps/dreki_web/src/dreki_web_ui_json_form.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl @@ -28,6 +28,8 @@ to_html([{Node, Attrs, Content} | Rest], Acc) -> AttrsS = attrs_to_html(Attrs), logger:debug("Node ~p Attrs ~p Content ~p", [Node, Attrs, Content]), to_html(Rest, [[<<"<">>, BNode, <<" ">>, AttrsS, <<">">>, to_html(Content), <<"</">>, BNode, <<">">>] | Acc]); +to_html([List | Rest], Acc) when is_list(List) -> + to_html(Rest, [to_html(List) | Acc]); to_html([], Acc) -> lists:reverse(Acc). @@ -45,13 +47,27 @@ attrs_to_html([{Attr, Value} | Rest], Acc) -> attrs_to_html([], Acc) -> Acc. +put_new(Key, Value, Map) -> + case maps:is_key(Key, Map) of + true -> + Map; + false -> + maps:put(Key, Value, Map) + end. + render(Schema, Opts) -> + render(Schema, Opts, {'div', [{class, <<"mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">>}]}). + +render(Schema, Opts, {Elem, Attrs}) -> + [{Elem, Attrs, render(Schema, Opts, undefined)}]; +render(Schema, Opts0, undefined) -> + Opts = put_new(name, <<"form">>, Opts0), lists:reverse(maps:fold(fun (Key, Value, Acc) -> - case render_property(Key, Value, Schema, Schema, Opts) of - {ok, Html} -> [Html | Acc]; - ignore -> Acc - end - end, [], maps:get(properties, Schema))). + case render_property(Key, Value, Schema, Schema, Opts) of + {ok, Html} -> [Html | Acc]; + ignore -> Acc + end + end, [], maps:get(properties, Schema))). render_property(Field, Config = #{enum := Enum}, Parent, Schema, Opts) -> FormAttrs = maps:get(<<"dreki:form">>, Config, #{}), @@ -69,6 +85,19 @@ render_property(Field, Config = #{type := Type}, Parent, Schema, Opts) -> InputOpts = #{required => Required, label => Label, input_type => InputType}, {ok, render_input(Input, Field, Type, InputOpts, Opts)}; +render_property(Key, SubSchema = #{<<"@schema">> := SubSchemaUrn}, Parent, Schema, Opts) -> + Name = maps:get(name, Opts), + KeyB = atom_to_binary(Key), + InnerForm = render(SubSchema, Opts#{name => <<Name/binary, "[", KeyB/binary, "]">>}, undefined), + Head = {'div', [{class, <<"sm:col-span-6 mb-3">>}], [ + {h3, [{class, <<"text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">>}], maps:get(title, SubSchema, SubSchemaUrn)}, + {p, [{class, <<"mt-1 text-sm text-gray-500 dark:text-gray-400">>}], maps:get(description, SubSchema, <<>>)} + ]}, + {ok, {'div', [{class, <<"sm:col-span-6">>}], + [ + {'div', [{class, <<"mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">>}], [Head, InnerForm]} + ]}}; + render_property(Field, #{'$ref' := Ref}, Parent, Schema = #{'$defs' := Defs}, Opts) -> case maps:get(Ref, Defs, undefined) of undefined -> logger:error("didn't get ref ~p", [Ref]); @@ -88,26 +117,30 @@ base_attributes(Field, IOpts, FOpts) -> render_input(select, Field, _, IOpts, FOpts) -> {Name, Attributes} = base_attributes(Field, IOpts, FOpts), OptionsHtml = lists:map(fun (Opt) -> {option, [], Opt} end, maps:get(values, IOpts, [])), - {'div', [{class, <<"json-field">>}], [ - {label, [{for, Name}], maps:get(label, IOpts, Field)}, + {'div', [{class, <<"json-field">>}], + [ + label(Field, Name, maps:get(label, IOpts, Field)), {select, Attributes, OptionsHtml} - ]}; + ]}; render_input(input, Field, Type, IOpts, FOpts) -> - {Name, Attributes0} = base_attributes(Field, IOpts, FOpts), - Attributes = [ - {value, maps:get(value, IOpts, undefined)}, - {placeholder, maps:get(placeholder, IOpts, undefined)}, - {readonly, maps:get(readonly, IOpts, false)}, - {autocomplete, maps:get(autocomplete, IOpts, <<"off">>)}, - {type, maps:get(input_type, IOpts)}, - {class, <<"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">>} - | Attributes0], - HtmlNode = {'div', [{class, <<"json-field">>}], [ - {label, [{for, Name}, {class, <<"block text-sm font-medium text-gray-700">>}], maps:get(label, IOpts, Field)}, - {'div', [{class, <<"mt-1">>}], [{input, Attributes}]} - ]}, - HtmlNode. + {Name, Attributes0} = base_attributes(Field, IOpts, FOpts), + Attributes = [ + {value, maps:get(value, IOpts, undefined)}, + {placeholder, maps:get(placeholder, IOpts, undefined)}, + {readonly, maps:get(readonly, IOpts, false)}, + {autocomplete, maps:get(autocomplete, IOpts, <<"off">>)}, + {type, maps:get(input_type, IOpts)}, + {class, <<"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-800">>} + | Attributes0], + {'div', [{class, <<"json-field sm:col-span-6">>}], + [ + label(Field, Name, maps:get(label, IOpts, Field)), + {'div', [{class, <<"mt-1">>}], [{input, Attributes}]} + ]}. + +label(_Field, Name, Label) -> + {label, [{for, Name}, {class, <<"block text-sm font-medium dark:text-gray-400 text-gray-700 dark:text-gray-400">>}], Label}. as_binary(Atom) when is_atom(Atom) -> atom_to_binary(Atom); @@ -119,7 +152,7 @@ input_type(_) -> <<"text">>. field_name(Field, Opts) -> FB = as_binary(Field), BaseName = maps:get(name, Opts, <<"form">>), - <<"[", BaseName/binary, "]", FB/binary>>. + <<BaseName/binary, "[", FB/binary, "]">>. field_id(Field, Opts) -> FB = as_binary(Field), diff --git a/apps/dreki_web/src/dreki_web_ui_node.erl b/apps/dreki_web/src/ui/dreki_web_ui_node.erl index fdb7c77..fdb7c77 100644 --- a/apps/dreki_web/src/dreki_web_ui_node.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_node.erl diff --git a/apps/dreki_web/src/dreki_web_ui_stores.erl b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl index d39e571..e39cd48 100644 --- a/apps/dreki_web/src/dreki_web_ui_stores.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl @@ -63,13 +63,17 @@ request(<<"GET">>, undefined, #{resource := #{namespace := NS}}, Req) -> %% List request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{directory := #{directory := Dir, namespace := NS}}}, Req) -> {ok, Result} = dreki_store:list(Urn), - Results = lists:map(fun(Result) -> + Results0 = lists:map(fun(Result) -> Result#{href => urn_to_path(maps:get('@id', Result))} end, maps:get(data, Result)), + Results = deep_map_to_list(Results0), Html = dreki_web_ui:render(Req, store_list_dtl, [ - {location, Loc}, {namespace, NS}, {directory, Dir}, {results, Results}, - {new, href(Req, <<"_/new">>)} - ]), + {location, Loc}, {namespace, NS}, {directory, Dir}, + {results, Results}, + {actions, [ + [{title, "New"}, {href, href(Req, <<"_/new">>)}] + ]} + ]), {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}; %% New @@ -90,8 +94,54 @@ request(<<"GET">>, <<"new">>, #{urn := Urn, location := Loc, resource := #{direc request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) -> {ok, Result0} = dreki_store:get(Urn), Result = Result0#{'@href' => urn_to_path(Urn)}, - Html = dreki_web_ui:render(Req, store_show_dtl, [{location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS}, {result, Result}]), - {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}. + Actions = lists:foldr(fun (#{id := Id, title := Title}, Acc) -> + IdB = atom_to_binary(Id), + Action = [{id, Id}, {title, Title}, {href, href(Req, <<"_/", IdB/binary>>)}], + [Action | Acc] + end, [], maps:get('@actions', Result)), + Html = dreki_web_ui:render(Req, store_show_dtl, [{location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS}, + {result, deep_map_to_list(Result)}, + {actions, [ + [{title, "Edit (UI)"}, {href, href(Req, <<"_/edit">>)}] + | Actions + ]} + ]), + {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}; + +%% Show action +request(<<"GET">>, Action0, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) -> + {ok, Result0} = dreki_store:get(Urn), + Result = Result0#{'@href' => urn_to_path(Urn)}, + Action = binary_to_existing_atom(Action0), + Path = href(Req, <<"_/", Action0/binary>>), + Act = lists:filter(fun (A) -> maps:get(id, A) =:= Action end, maps:get('@actions', Result)), + case Act of + [#{title := Title, new := {Mod, Fun, Args}}] -> + {ok, Schema} = apply(Mod, Fun, [Result | Args]), + Form = dreki_web_ui_json_form:render_html(Schema, #{}), + Html = dreki_web_ui:render(Req, store_new_dtl, [ + {location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS}, + {method, <<"POST">>}, + {title, Title}, + {result, deep_map_to_list(Result)}, + {target, Path}, + {form, Form} + ]), + {dreki_web_ui:reply_html(Req, 200, Html), undefined}; + _ -> + dreki_web_ui_error:init(Req, #{code => 404, status => "Not Found", message => "No such action"}) + end; +request(<<"POST">>, Action0, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) -> + {ok, Result0} = dreki_store:get(Urn), + Result = Result0#{'@href' => urn_to_path(Urn)}, + Action = binary_to_existing_atom(Action0), + Act = lists:filter(fun (A) -> maps:get(id, A) =:= Action end, maps:get('@actions', Result)), + case Act of + [#{title := Title, new := {Mod, Fun, Args}}] -> + Req; + _ -> + dreki_web_ui_error:init(Req, #{code => 404, status => "Not Found", message => "No such action"}) + end. derpinit(Req, _) -> Json = #{<<"error">> => false, <<"service">> => <<"dreki">>}, @@ -112,6 +162,19 @@ location_to_path(Location) -> false -> binary:replace(Location, <<Root/binary, ":">>, <<"/admin/">>) end. +deep_map_to_list(List) when is_list(List) -> + [deep_map_to_list(Map) || Map <- List]; +deep_map_to_list(Map) when is_map(Map) -> + deep_map_to_list(maps:to_list(Map), []). + +deep_map_to_list([{Key, Map} | Rest], Acc) when is_map(Map) -> + List = deep_map_to_list(Map), + deep_map_to_list(Rest, [{Key, List} | Acc]); +deep_map_to_list([Item | Rest], Acc) -> + deep_map_to_list(Rest, [Item | Acc]); +deep_map_to_list([], Acc) -> + Acc. + urn_to_path(Urn) when is_binary(Urn) -> {ok, XUrn} = dreki_urn:expand(Urn), urn_to_path(XUrn); diff --git a/apps/dreki_web/src/dreki_web_ui_task.erl b/apps/dreki_web/src/ui/dreki_web_ui_task.erl index 1ced583..1ced583 100644 --- a/apps/dreki_web/src/dreki_web_ui_task.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_task.erl diff --git a/apps/dreki_web/src/dreki_web_ui_tasks.erl b/apps/dreki_web/src/ui/dreki_web_ui_tasks.erl index e9748b8..e9748b8 100644 --- a/apps/dreki_web/src/dreki_web_ui_tasks.erl +++ b/apps/dreki_web/src/ui/dreki_web_ui_tasks.erl diff --git a/apps/dreki_web/templates/crash.dtl b/apps/dreki_web/templates/crash.dtl new file mode 100644 index 0000000..b0a291c --- /dev/null +++ b/apps/dreki_web/templates/crash.dtl @@ -0,0 +1,52 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{% if page_title %}{{ page_title }} - {% endif %}{{site_title}}</title> + <link rel="stylesheet" href="/static/app.css"> + <script src="/static/app.js" defer></script> + <link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png"> + <link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png"> +</head> +<body class="dark:bg-[#2E0E02] dark:text-orange-100 bg-orange-100 text-orange-900"> +<header class="bg-orange-600 dark:bg-red-900"> +<div class="container mx-auto sm:px-6 lg:px-8"> + + <nav class="" aria-label="Top"> + <div class="w-full py-6 flex items-center justify-between border-b border-primary-500 lg:border-none"> + <div class="flex items-center"> + <a href="/admin" class="text-primary-100 hover:text-white text-lg"> + <span class="sr-only">{{site_title}}</span> +<div class="text-[2rem] inline-block">😈</span> +<!--<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> +</svg>--> + </a> + </nav> +</div> +</header> + +<div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6"> +<div class="content"> + <h1 class="text-7xl text-red-600 dark:text-red-100 font-bold font-mono">😤<br/>{{ class }}</h1> + <h2 class="text-4xl text-orange-800 dark:text-orange-300 font-bold font-mono mt-10 mb-10">{{ reason }}</h2> + {% for line in stacktrace %} + <h3 class="text-lg text-stone-400 dark:text-stone:500 -mb-2 font-mono">{{ line }}</h3> + {% endfor %} +</div> +</div> + +<footer class=""> + <div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6"> + <p class="mt-8 text-xs text-gray-400 font-mono"> + node: {{ dreki_node }} + </p> + <p class="mt-2 text-xs text-gray-400 font-mono"> + trace: <a href="https://grafana.adm.random.sh/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Tempo%20(Stairway)%22,%7B%22refId%22:%22A%22,%22queryType%22:%22traceId%22,%22query%22:%22{{trace_id}}%22%7D%5D" target="_blank">{{ trace_id }}</a> + </p> + </footer> +</div> +</body> +</html> diff --git a/apps/dreki_web/templates/layout.dtl b/apps/dreki_web/templates/layout.dtl index b27fe30..a326305 100644 --- a/apps/dreki_web/templates/layout.dtl +++ b/apps/dreki_web/templates/layout.dtl @@ -73,10 +73,11 @@ <footer class=""> <div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6"> <p class="mt-8 text-center text-sm text-gray-400"> - dreki $nodename + dreki {{dreki_node}} </p> - <p class="mt-2 text-center text-xs text-gray-300"> - identity:{{identity_id}} + <p class="mt-2 text-center text-xs text-gray-400"> + identity: {{identity_id}}<br /> + trace: <a href="https://grafana.adm.random.sh/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Tempo%20(Stairway)%22,%7B%22refId%22:%22A%22,%22queryType%22:%22traceId%22,%22query%22:%22{{trace_id}}%22%7D%5D" target="_blank">{{trace_id}}> </p> </footer> </div> diff --git a/apps/dreki_web/templates/store_list.dtl b/apps/dreki_web/templates/store_list.dtl index 65dc5af..81209a6 100644 --- a/apps/dreki_web/templates/store_list.dtl +++ b/apps/dreki_web/templates/store_list.dtl @@ -1,9 +1,11 @@ <h1 class="text-lg">{{ location }} :: {{ namespace }}:{{ directory }}</h1> <ul> -{% for res in result %} -<li><a href="{{ res.href }}">{{ res.id }}</a></li> +{% for res in results %} +<li><a href="{{ res.href }}" class="text-primary-700 dark:text-primary-100">{{ res.id }}</a> {{ res.params.title }}</li> {% endfor %} </ul> -<a href="{{ new }}">Create</a> +{% for action in actions %} +<a href="{{ action.href }}">{{ action.title }}</a> +{% endfor %} diff --git a/apps/dreki_web/templates/store_new.dtl b/apps/dreki_web/templates/store_new.dtl index 5a85854..0ae8d59 100644 --- a/apps/dreki_web/templates/store_new.dtl +++ b/apps/dreki_web/templates/store_new.dtl @@ -2,14 +2,18 @@ <div class="text-base"> {{ location }} / {{ namespace }} / {{ directory }} </div> - New + {% if result %} + {{ result.id }}: {{ title }} + {% else %} + New + {% endif %} </h1> -<form action="{{ target }}" method="{{ method }}"> +<form action="{{ target }}" method="{{ method }}" class="space-y-8 divide-y divide-gray-200"> {{ form | safe }} <div class="pt-5"> - <div class="flex justify-end"> + <div class="flex justify-start"> <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Save</button> </div> </div> diff --git a/apps/dreki_web/templates/store_show.dtl b/apps/dreki_web/templates/store_show.dtl index e944bee..3b139bb 100644 --- a/apps/dreki_web/templates/store_show.dtl +++ b/apps/dreki_web/templates/store_show.dtl @@ -5,4 +5,12 @@ {{ result.id }} </h1> +<ul> +{% for res in result %} +<li>{{res}}</li> +{% endfor %} +</ul> +{% for action in actions %} +<a href="{{ action.href }}">{{ action.title }}</a> +{% endfor %} |