2018-06-02 23:31:58 +09:00

657 lines
22 KiB
Erlang

%%% @doc
%%% ZX
%%%
%%% A general dependency and packaging tool that works together with the zomp
%%% package manager. Given a project directory with a standard layout, zx can:
%%% - Initialize your project for packaging and semver tracking under zomp.
%%% - Add dependencies (recursively) defined in any zomp repository realm.
%%% - Update dependencies (recursively) defined in any zomp repository realm.
%%% - Remove dependencies.
%%% - Update, upgrade or run any application from source that zomp tracks.
%%% - Locally install packages from files and locally stored public keys.
%%% - Build and run a local project from source using zomp dependencies.
%%% @end
-module(zx).
-behavior(application).
-author("Craig Everett <zxq9@zxq9.com>").
-copyright("Craig Everett <zxq9@zxq9.com>").
-license("GPL-3.0").
-export([do/0, do/1]).
-export([subscribe/1, unsubscribe/0]).
-export([start/2, stop/1, stop/0]).
-export([usage_exit/1]).
-export_type([serial/0, package_id/0, package/0, realm/0, name/0, version/0,
identifier/0,
host/0,
key_id/0, key_name/0,
user_id/0, user_name/0, contact_info/0, user_data/0,
lower0_9/0, label/0,
package_meta/0,
outcome/0]).
-include("zx_logger.hrl").
%%% Type Definitions
-type serial() :: integer().
-type package_id() :: {realm(), name(), version()}.
-type package() :: {realm(), name()}.
-type realm() :: lower0_9().
-type name() :: lower0_9().
-type version() :: {Major :: non_neg_integer() | z,
Minor :: non_neg_integer() | z,
Patch :: non_neg_integer() | z}.
-type host() :: {string() | inet:ip_address(), inet:port_number()}.
-type key_id() :: {realm(), key_name()}.
-type key_name() :: lower0_9().
-type user_id() :: {realm(), user_name()}.
-type user_name() :: label().
-type contact_info() :: {Type :: string(), Data :: string()}.
-type user_data() :: {ID :: user_id(),
RealName :: string(),
Contact :: [contact_info()],
Keys :: [key_name()]}.
-type lower0_9() :: [$a..$z | $0..$9 | $_].
-type label() :: [$a..$z | $0..$9 | $_ | $- | $.].
-type package_meta() :: #{package_id := package_id(),
deps := [package_id()],
type := app | lib}.
-type outcome() :: ok
| {error, Reason :: term()}
| {error, Code :: non_neg_integer()}
| {error, Info :: string(), Code :: non_neg_integer()}.
%%% Command Dispatch
-spec do() -> no_return().
do() ->
do([]).
-spec do(Args) -> no_return()
when Args :: [string()].
%% Dispatch work functions based on the nature of the input arguments.
do(["help"]) ->
usage_exit(0);
do(["run", PackageString | Args]) ->
ok = start(),
run(PackageString, Args);
do(["runlocal" | ArgV]) ->
ok = start(),
run_local(ArgV);
do(["init", "app", PackageString]) ->
ok = compatibility_check([unix]),
done(zx_local:initialize(app, PackageString));
do(["init", "lib", PackageString]) ->
ok = compatibility_check([unix]),
done(zx_local:initialize(lib, PackageString));
do(["list", "deps"]) ->
done(zx_local:list_deps());
do(["list", "deps", PackageString]) ->
done(zx_local:list_deps(PackageString));
do(["import", "zrp", PackageFile]) ->
done(zx_daemon:import_zrp(PackageFile));
do(["install", PackageString]) ->
done(zx_daemon:install(PackageString));
do(["set", "dep", PackageString]) ->
done(zx_local:set_dep(PackageString));
do(["set", "version", VersionString]) ->
ok = compatibility_check([unix]),
done(zx_local:set_version(VersionString));
do(["verup", Level]) ->
ok = compatibility_check([unix]),
done(zx_local:verup(Level));
do(["list", "realms"]) ->
done(zx_local:list_realms());
do(["list", "packages", Realm]) ->
ok = start(),
done(zx_local:list_packages(Realm));
do(["list", "versions", PackageName]) ->
ok = start(),
done(zx_local:list_versions(PackageName));
do(["add", "realm", RealmFile]) ->
done(zx_local:add_realm(RealmFile));
do(["drop", "dep", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
done(zx_local:drop_dep(PackageID));
do(["package"]) ->
{ok, TargetDir} = file:get_cwd(),
zx_local:package(TargetDir);
do(["package", TargetDir]) ->
case filelib:is_dir(TargetDir) of
true -> done(zx_local:package(TargetDir));
false -> done({error, "Target directory does not exist", 22})
end;
do(["dialyze"]) ->
done(zx_local:dialyze());
do(["create", "user"]) ->
done(zx_local:create_user());
do(["create", "keypair"]) ->
done(zx_local:grow_a_pair());
do(["drop", "key", Realm, KeyName]) ->
done(zx_local:drop_key({Realm, KeyName}));
do(["create", "plt"]) ->
done(zx_local:create_plt());
do(["create", "realm"]) ->
done(zx_local:create_realm());
do(["create", "realmfile", Realm]) ->
done(zx_local:create_realmfile(Realm, "."));
do(["takeover", Realm]) ->
done(zx_local:takeover(Realm));
do(["abdicate", Realm]) ->
done(zx_local:abdicate(Realm));
do(["drop", "realm", Realm]) ->
done(zx_local:drop_realm(Realm));
do(["list", "pending", PackageName]) ->
done(zx_auth:list_pending(PackageName));
do(["list", "resigns", Realm]) ->
done(zx_auth:list_resigns(Realm));
do(["submit", PackageFile]) ->
done(zx_auth:submit(PackageFile));
do(["review", PackageString]) ->
done(zx_auth:review(PackageString));
do(["approve", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
done(zx_auth:approve(PackageID));
do(["reject", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
done(zx_auth:reject(PackageID));
do(["accept", PackageString]) ->
done(zx_auth:accept(PackageString));
do(["add", "packager", Package, UserName]) ->
done(zx_auth:add_packager(Package, UserName));
do(["add", "maintainer", Package, UserName]) ->
done(zx_auth:add_maintainer(Package, UserName));
do(["add", "sysop", Package, UserName]) ->
done(zx_auth:add_sysop(Package, UserName));
do(["add", "package", PackageName]) ->
done(zx_auth:add_package(PackageName));
do(_) ->
usage_exit(22).
-spec done(outcome()) -> no_return().
done(ok) ->
halt(0);
done({error, Reason}) when is_atom(Reason) ->
ok = log(error, "Operation failed with: ~tp", [Reason]),
halt(1);
done({error, Code}) when is_integer(Code) ->
halt(Code);
done({error, Info, Code}) ->
ok = log(error, Info),
halt(Code).
-spec compatibility_check(Platforms) -> ok | no_return()
when Platforms :: unix | win32.
%% @private
%% Some commands only work on specific platforms because they leverage some specific
%% aspect on that platform, but not common to all. ATM this is mostly developer
%% commands that leverage things universal to *nix/posix shells but not Windows.
%% If equivalent procedures are written in Erlang then these restrictions can be
%% avoided -- but it is unclear whether there are any Erlang developers even using
%% Windows, so for now this is the bad version of the solution.
compatibility_check(Platforms) ->
{Family, Name} = os:type(),
case lists:member(Family, Platforms) of
true ->
ok;
false ->
Message = "Unfortunately this command is not available on ~tp ~tp",
ok = log(error, Message, [Family, Name]),
halt(0)
end.
%%% Application Start/Stop
-spec start() -> ok | {error, Reason :: term()}.
%% @doc
%% An alias for `application:ensure_started(zx)', meaning it is safe to call this
%% function more than once, or within a system where you are unsure whether zx is
%% already running (perhaps due to complex dependencies that require zx already).
%% In the typical case this function does not ever need to be called, because the
%% zx_daemon is always started in the background whenever an application is started
%% using the command `zx run [app_id]'.
%% @equiv application:ensure_started(zx).
start() ->
application:ensure_started(zx).
-spec stop() -> ok | {error, Reason :: term()}.
%% @doc
%% A safe wrapper for `application:stop(zx)'. Similar to `ensure_started/1,2', returns
%% `ok' in the case that zx is already stopped.
stop() ->
case application:stop(zx) of
ok -> ok;
{error, {not_started, zx}} -> ok;
Error -> Error
end.
%%% Application Callbacks
-spec start(StartType, StartArgs) -> Result
when StartType :: normal,
StartArgs :: [],
Result :: {ok, pid()}.
%% @private
%% Application callback. Not to be called directly.
start(normal, []) ->
ok = application:ensure_started(inets),
zx_daemon:start_link().
-spec stop(term()) -> ok.
%% @private
%% Application callback. Not to be called directly.
stop(_) ->
ok.
%%% Daemon Controls
-spec subscribe(package()) -> ok | {error, Reason :: term()}.
%% @doc
%% Initiates the zx_daemon and instructs it to subscribe to a package.
%%
%% Any events in the Zomp network that apply to the subscribed package will be
%% forwarded to the process that originally called subscribe/1. How the original
%% caller reacts to these notifications is up to the author -- not reply or "ack"
%% is expected.
%%
%% Package subscriptions can be used as the basis for user notification of updates,
%% automatic upgrade restarts, package catalog tracking, etc.
subscribe(Package) ->
case application:start(?MODULE, normal) of
ok -> zx_daemon:subscribe(Package);
Error -> Error
end.
-spec unsubscribe() -> ok | {error, Reason :: term()}.
%% @doc
%% Unsubscribes from package updates.
unsubscribe() ->
zx_daemon:unsubscribe().
%%% Execution of application
-spec run(Identifier, RunArgs) -> no_return()
when Identifier :: string(),
RunArgs :: [string()].
%% @private
%% Given a program Identifier and a list of Args, attempt to locate the program and its
%% dependencies and run the program. This implies determining whether the program and
%% its dependencies are installed, available, need to be downloaded, or are inaccessible
%% given the current system condition (they could also be bogus, of course). The
%% Identifier must be a valid package string of the form `realm-appname[-version]'
%% where the `realm()' and `name()' must follow Zomp package naming conventions and the
%% version should be represented as a semver in string form (where ommitted elements of
%% the version always default to whatever is most current).
%%
%% Once the target program is running, this process, (which will run with the registered
%% name `zx') will sit in an `exec_wait' state, waiting for either a direct message from
%% a child program or for calls made via zx_lib to assist in environment discovery.
%%
%% If there is a problem anywhere in the locating, discovery, building, and loading
%% procedure the runtime will halt with an error message.
run(Identifier, RunArgs) ->
ok = file:set_cwd(zx_lib:zomp_dir()),
FuzzyID =
case zx_lib:package_id(Identifier) of
{ok, Fuzzy} ->
Fuzzy;
{error, invalid_package_string} ->
error_exit("Bad package string: ~ts", [Identifier], ?LINE)
end,
{ok, PackageID} = ensure_installed(FuzzyID),
ok = build(PackageID),
Dir = zx_lib:ppath(lib, PackageID),
{ok, Meta} = zx_lib:read_project_meta(Dir),
prepare(PackageID, Meta, Dir, RunArgs).
-spec run_local(RunArgs) -> no_return()
when RunArgs :: [term()].
%% @private
%% Execute a local project from source from the current directory, satisfying dependency
%% requirements via the locally installed zomp lib cache. The project must be
%% initialized as a zomp project (it must have a valid `zomp.meta' file).
%%
%% The most common use case for this function is during development. Using zomp support
%% via the local lib cache allows project authors to worry only about their own code
%% and use zx commands to add or drop dependencies made available via zomp.
run_local(RunArgs) ->
{ok, Meta} = zx_lib:read_project_meta(),
PackageID = maps:get(package_id, Meta),
ok = zx_lib:build(),
{ok, Dir} = file:get_cwd(),
ok = file:set_cwd(zx_lib:zomp_dir()),
prepare(PackageID, Meta, Dir, RunArgs).
-spec prepare(PackageID, Meta, Dir, RunArgs) -> no_return()
when PackageID :: package_id(),
Meta :: package_meta(),
Dir :: file:filename(),
RunArgs :: [string()].
%% @private
%% Execution prep common to all packages.
prepare(PackageID, Meta, Dir, RunArgs) ->
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, "Preparing ~ts...", [PackageString]),
Type = maps:get(type, Meta),
Deps = maps:get(deps, Meta),
NotInstalled = fun(P) -> not filelib:is_dir(zx_lib:ppath(lib, P)) end,
Needed = lists:filter(NotInstalled, Deps),
Pending = lists:map(fun zx_daemon:fetch/1, Needed),
case await_fetches(Pending) of
ok ->
ok = lists:foreach(fun install/1, Needed),
ok = lists:foreach(fun build/1, Needed),
execute(Type, PackageID, Meta, Dir, RunArgs);
{error, Errors} ->
error_exit("Failed package fetches: ~tp", [Errors], ?LINE)
end.
await_fetches([]) -> ok;
await_fetches(Pending) -> await_fetches(Pending, []).
await_fetches([], []) ->
ok;
await_fetches([], Errors) ->
{error, Errors};
await_fetches(Pending, Errors) ->
{NewPending, NewErrors} =
receive
{z_reply, ID, ok} ->
{lists:delete(ID, Pending), Errors};
{z_reply, ID, {error, Package, Reason}} ->
{lists:delete(ID, Pending), [{Package, Reason} | Errors]}
end,
await_fetches(NewPending, NewErrors).
-spec execute(Type, PackageID, Meta, Dir, RunArgs) -> no_return()
when Type :: app | lib,
PackageID :: package_id(),
Meta :: package_meta(),
Dir :: file:filename(),
RunArgs :: [string()].
%% @private
%% Gets all the target application's ducks in a row and launches them, then enters
%% the exec_wait/1 loop to wait for any queries from the application.
execute(app, PackageID, Meta, Dir, RunArgs) ->
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, "Starting ~ts.", [PackageString]),
Name = element(2, PackageID),
AppTag = list_to_atom(Name),
{AppMod, _} = maps:get(appmod, Meta),
ok = zx_daemon:pass_meta(Meta, Dir, RunArgs),
ok = ensure_all_started(AppTag),
ok = pass_argv(AppMod, RunArgs),
log(info, "Launcher complete.");
execute(lib, PackageID, _, _, _) ->
Message = "Lib ~ts is available on the system, but is not a standalone app.",
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, Message, [PackageString]),
halt(0).
-spec ensure_all_started(AppMod) -> ok
when AppMod :: module().
%% @private
%% Wrap a call to application:ensure_all_started/1 to selectively provide output
%% in the case any dependencies are actually started by the call. Might remove this
%% depending on whether SASL winds up becoming a standard part of the system and
%% whether it becomes common for dependencies to all signal their own start states
%% somehow.
ensure_all_started(AppMod) ->
case application:ensure_all_started(AppMod) of
{ok, []} -> ok;
{ok, Apps} -> log(info, "Started ~tp", [Apps])
end.
-spec pass_argv(AppMod, Args) -> ok
when AppMod :: module(),
Args :: [string()].
%% @private
%% Check whether the AppMod:accept_argv/1 is implemented. If so, pass in the
%% command line arguments provided.
pass_argv(AppMod, Args) ->
case lists:member({accept_argv, 1}, AppMod:module_info(exports)) of
true -> AppMod:accept_argv(Args);
false -> ok
end.
-spec ensure_installed(PackageID) -> Result | no_return()
when PackageID :: package_id(),
Result :: {ok, ActualID :: package_id()}.
%% @private
%% Given a PackageID, check whether it is installed on the system, and if not, ensure
%% that the package is either in the cache or can be downloaded. If all attempts at
%% locating or acquiring the package fail, then exit with an error.
ensure_installed(PackageID = {Realm, Name, Version}) ->
case resolve_installed_version(PackageID) of
exact -> {ok, PackageID};
{ok, Installed} -> {ok, {Realm, Name, Installed}};
not_found -> fetch_one({Realm, Name, Version})
end.
-spec fetch_one(PackageID) -> {ok, ActualID} | no_return()
when PackageID :: package_id(),
ActualID :: package_id().
%% @private
%% A helper function to deal with the special case of downloading and installing a
%% single primary application package with a possibly incomplete version designator.
%% All other fetches are for arbitrarily long lists of package IDs with complete
%% version numbers (dependency fetches).
fetch_one(PackageID) ->
case zx_daemon:fetch([PackageID]) of
{{ok, [ActualID]}, {error, []}} ->
ok = install(ActualID),
{ok, ActualID};
{{ok, []}, {error, [{PackageID, Reason}]}} ->
error_exit("Package fetch failed with: ~tp", [Reason], ?LINE)
end.
-spec resolve_installed_version(PackageID) -> Result
when PackageID :: package_id(),
Result :: not_found
| exact
| {ok, Installed :: version()}.
%% @private
%% Resolve the provided PackageID to the latest matching installed package directory
%% version if one exists, returning a value that indicates whether an exact match was
%% found (in the case of a full version input), a version matching a partial version
%% input was found, or no match was found at all.
resolve_installed_version({Realm, Name, Version}) ->
PackageDir = zx_lib:path(lib, Realm, Name),
case filelib:is_dir(PackageDir) of
true -> resolve_installed_version(PackageDir, Version);
false -> not_found
end.
resolve_installed_version(PackageDir, Version) ->
DirStrings = filelib:wildcard("*", PackageDir),
Versions = lists:fold(fun tuplize/2, [], DirStrings),
zx_lib:find_latest_compatible(Version, Versions).
tuplize(String, Acc) ->
case zx_lib:string_to_version(String) of
{ok, Version} -> [Version | Acc];
_ -> Acc
end.
-spec build(package_id()) -> ok.
%% @private
%% Given an AppID, build the project from source and add it to the current lib path.
build(PackageID) ->
{ok, CWD} = file:get_cwd(),
ok = file:set_cwd(zx_lib:ppath(lib, PackageID)),
ok = zx_lib:build(),
file:set_cwd(CWD).
-spec ensure_package_dirs(package_id()) -> ok.
%% @private
%% Procedure to guarantee that directory locations necessary for the indicated app to
%% run have been created or halt execution.
ensure_package_dirs(PackageID) ->
Dirs = [zx_lib:ppath(D, PackageID) || D <- [etc, var, tmp, log, lib]],
lists:foreach(fun zx_lib:force_dir/1, Dirs).
%%% Usage
-spec usage_exit(Code) -> no_return()
when Code :: integer().
%% @private
%% A convenience function that will display the zx usage message before halting
%% with the provided exit code.
usage_exit(Code) ->
ok = usage(),
halt(Code).
-spec usage() -> ok.
%% @private
%% Display the zx command line usage message.
usage() ->
T =
"ZX usage: zx [command] [object] [args]~n"
"~n"
"User Actions:~n"
" zx help~n"
" zx run PackageID [Args]~n"
" zx runlocal [Args]~n"
" zx list realms~n"
" zx list packages Realm~n"
" zx list versions PackageID~n"
" zx latest PackageID~n"
" zx add realm RealmFile~n"
" zx drop realm Realm~n"
" zx install PackageID~n"
" zx logpath [Package [1-10]]~n"
" zx status~n"
" zx set timeout Value~n"
" zx set mirror Realm Host:Port~n"
"~n"
"Developer/Packager/Maintainer Actions:~n"
" zx create project [app | lib] PackageID~n"
" zx init Type PackageID~n"
" zx list deps [PackageID]~n"
" zx set dep PackageID~n"
" zx drop dep PackageID~n"
" zx verup Level~n"
" zx set version Version~n"
" zx update .app~n"
" zx create plt~n"
" zx dialyze~n"
" zx package Path~n"
" zx submit ZspFile~n"
" zx list pending PackageName~n"
" zx list resigns Realm~n"
" zx list packagers PackageName~n"
" zx list maintainers PackageName~n"
" zx list sysops Realm~n"
" zx review PackageID~n"
" zx approve PackageID~n"
" zx reject PackageID~n"
" zx add key Realm KeyName~n"
" zx get key Realm KeyName~n"
" zx create user~n"
" zx create userfiles Realm UserName~n"
" zx create keypair~n"
" zx export user UserID~n"
" zx import user ZdufFile~n"
"~n"
"Sysop Actions:~n"
" zx add user ZpufFile~n"
" zx add package PackageName~n"
" zx add packager PackageName UserID~n"
" zx add maintainer PackageName UserID~n"
" zx add sysop UserID~n"
" zx accept PackageID~n"
" zx create realm~n"
" zx create realmfile Realm~n"
"~n"
"Where~n"
" PackageID :: A string of the form Realm-Name[-Version]~n"
" Args :: Arguments to pass to the application~n"
" Type :: The project type: a standalone \"app\" or a \"lib\"~n"
" Version :: Version string X, X.Y, or X.Y.Z: \"1\", \"1.2\", \"1.2.3\"~n"
" RealmFile :: Path to a valid .zrf realm file~n"
" Realm :: The name of a realm as a string [:a-z:]~n"
" KeyName :: The prefix of a keypair to drop~n"
" Level :: The version level, one of \"major\", \"minor\", or \"patch\"~n"
" Path :: Path to a valid project directory or .zsp file~n",
io:format(T).
%%% Error exits
-spec error_exit(Format, Args, Line) -> no_return()
when Format :: string(),
Args :: [term()],
Line :: non_neg_integer().
%% @private
%% Format an error message in a way that makes it easy to locate.
error_exit(Format, Args, Line) ->
File = filename:basename(?FILE),
ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]),
halt(1).