3256 lines
109 KiB
Erlang
3256 lines
109 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).
|
|
-export([start/2, main/1]).
|
|
|
|
|
|
-export_type([serial/0, package_id/0, package/0, realm/0, name/0, version/0, option/0,
|
|
host/0, key_id/0, key_name/0, user/0, username/0, lower0_9/0, label/0,
|
|
package_meta/0]).
|
|
|
|
|
|
-record(s,
|
|
{realm = "otpr" :: realm(),
|
|
name = none :: none | name(),
|
|
version = {z, z, z} :: version(),
|
|
type = app :: app | lib,
|
|
deps = [] :: [package_id()],
|
|
serial = 0 :: serial(),
|
|
dir = none :: none | file:filename(),
|
|
socket = none :: none | gen_tcp:socket(),
|
|
pid = none :: none | pid(),
|
|
mon = none :: none | reference()}).
|
|
|
|
|
|
-type state() :: #s{}.
|
|
-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 option() :: {string(), term()}.
|
|
-type host() :: {string() | inet:ip_address(), inet:port_number()}.
|
|
%-type keybin() :: {ID :: key_id(),
|
|
% Type :: public | private,
|
|
% DER :: binary()}.
|
|
-type key_id() :: {realm(), key_name()}.
|
|
-type key_name() :: label().
|
|
-type user() :: {realm(), username()}.
|
|
-type username() :: label().
|
|
-type lower0_9() :: [$a..$z | $0..$9 | $_].
|
|
-type label() :: [$a..$z | $0..$9 | $_ | $- | $.].
|
|
-type package_meta() :: map().
|
|
|
|
|
|
|
|
-spec main(Args) -> no_return()
|
|
when Args :: [string()].
|
|
%% @private
|
|
%% The automatically exposed function initially called by escript to kick things off.
|
|
%% Args is a list of command-line provided arguments, all presented as a list of strings
|
|
%% delimited by whitespace in the shell.
|
|
|
|
main(Args) ->
|
|
ok = ensure_zomp_home(),
|
|
start(Args).
|
|
|
|
|
|
-spec start(Args) -> no_return()
|
|
when Args :: [string()].
|
|
%% Dispatch work functions based on the nature of the input arguments.
|
|
|
|
start(["help"]) ->
|
|
usage_exit(0);
|
|
start(["run", PackageString | Args]) ->
|
|
run(PackageString, Args);
|
|
start(["init", "app", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
initialize(app, PackageID);
|
|
start(["init", "lib", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
initialize(lib, PackageID);
|
|
start(["install", PackageFile]) ->
|
|
assimilate(PackageFile);
|
|
start(["set", "dep", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
set_dep(PackageID);
|
|
start(["set", "version", VersionString]) ->
|
|
set_version(VersionString);
|
|
start(["list", "realms"]) ->
|
|
list_realms();
|
|
start(["list", "packages", Realm]) ->
|
|
ok = valid_realm(Realm),
|
|
list_packages(Realm);
|
|
start(["list", "versions", PackageName]) ->
|
|
Package = string_to_package(PackageName),
|
|
list_versions(Package);
|
|
start(["list", "pending", PackageName]) ->
|
|
Package = string_to_package(PackageName),
|
|
list_pending(Package);
|
|
start(["list", "resigns", Realm]) ->
|
|
ok = valid_realm(Realm),
|
|
list_resigns(Realm);
|
|
start(["add", "realm", RealmFile]) ->
|
|
add_realm(RealmFile);
|
|
start(["add", "package", PackageName]) ->
|
|
add_package(PackageName);
|
|
start(["add", "packager", Package, UserName]) ->
|
|
add_packager(Package, UserName);
|
|
start(["add", "maintainer", Package, UserName]) ->
|
|
add_maintainer(Package, UserName);
|
|
start(["review", PackageString]) ->
|
|
review(PackageString);
|
|
start(["approve", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
approve(PackageID);
|
|
start(["reject", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
reject(PackageID);
|
|
start(["resign", PackageString]) ->
|
|
resign(PackageString);
|
|
start(["drop", "dep", PackageString]) ->
|
|
PackageID = package_id(PackageString),
|
|
drop_dep(PackageID);
|
|
start(["drop", "key", KeyID]) ->
|
|
drop_key(KeyID);
|
|
start(["drop", "realm", Realm]) ->
|
|
drop_realm(Realm);
|
|
start(["verup", Level]) ->
|
|
verup(Level);
|
|
start(["runlocal" | Args]) ->
|
|
run_local(Args);
|
|
start(["package"]) ->
|
|
{ok, TargetDir} = file:get_cwd(),
|
|
package(TargetDir);
|
|
start(["package", TargetDir]) ->
|
|
case filelib:is_dir(TargetDir) of
|
|
true ->
|
|
package(TargetDir);
|
|
false ->
|
|
ok = log(error, "Target directory ~tp does not exist!", [TargetDir]),
|
|
halt(22)
|
|
end;
|
|
start(["submit", PackageFile]) ->
|
|
submit(PackageFile);
|
|
start(["dialyze"]) ->
|
|
dialyze();
|
|
start(["create", "user", Realm, Name]) ->
|
|
create_user(Realm, Name);
|
|
start(["create", "keypair"]) ->
|
|
create_keypair();
|
|
start(["create", "plt"]) ->
|
|
create_plt();
|
|
start(["create", "realm"]) ->
|
|
create_realm();
|
|
start(["create", "realmfile", Realm]) ->
|
|
create_realmfile(Realm);
|
|
start(["create", "sysop"]) ->
|
|
create_sysop();
|
|
start(_) ->
|
|
usage_exit(22).
|
|
|
|
|
|
|
|
%%% Execution of application
|
|
|
|
-spec run(Identifier, Args) -> no_return()
|
|
when Identifier :: string(),
|
|
Args :: [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 should be a valid PackageString of the form `realm-appname-version'
|
|
%% where the realm and appname should follow standard realm and app 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, Args) ->
|
|
MaybeID = package_id(Identifier),
|
|
{ok, PackageID = {Realm, Name, Version}} = ensure_installed(MaybeID),
|
|
ok = file:set_cwd(zomp_dir()),
|
|
Dir = filename:join("lib", package_string(PackageID)),
|
|
Meta = read_meta(Dir),
|
|
Deps = maps:get(deps, Meta),
|
|
ok = ensure_deps(Deps),
|
|
State = #s{realm = Realm,
|
|
name = Name,
|
|
version = Version,
|
|
dir = Dir,
|
|
deps = Deps},
|
|
execute(State, Args).
|
|
|
|
|
|
|
|
%%% Project initialization
|
|
|
|
-spec initialize(Type, PackageID) -> no_return()
|
|
when Type :: app | lib,
|
|
PackageID :: package_id().
|
|
%% @private
|
|
%% Initialize an application in the local directory based on the PackageID provided.
|
|
%% This function does not care about the name of the current directory and leaves
|
|
%% providing a complete, proper and accurate PackageID.
|
|
%% This function will check the current `lib/' directory for zomp-style dependencies.
|
|
%% If this is not the intended function or if there are non-compliant directory names
|
|
%% in `lib/' then the project will need to be rearranged to become zomp compliant or
|
|
%% the `deps' section of the resulting meta file will need to be manually updated.
|
|
|
|
initialize(Type, PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
ok = log(info, "Initializing ~s...", [PackageString]),
|
|
MetaList = [{package_id, PackageID},
|
|
{deps, []},
|
|
{type, Type}],
|
|
Meta = maps:from_list(MetaList),
|
|
ok = write_meta(Meta),
|
|
ok = log(info, "Project ~tp initialized.", [PackageString]),
|
|
Message =
|
|
"NOTICE:~n"
|
|
" This project is currently listed as having no dependencies.~n"
|
|
" If this is not true then run `zx set dep DepID` for each current dependency.~n"
|
|
" (run `zx help` for more information on usage)~n",
|
|
ok = io:format(Message),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% Add a package from a local file
|
|
|
|
-spec assimilate(PackageFile) -> PackageID
|
|
when PackageFile :: file:filename(),
|
|
PackageID :: package_id().
|
|
%% @private
|
|
%% Receives a path to a file containing package data, examines it, and copies it to a
|
|
%% canonical location under a canonical name, returning the PackageID of the package
|
|
%% contents.
|
|
|
|
assimilate(PackageFile) ->
|
|
Files = extract_zrp(PackageFile),
|
|
{ok, CWD} = file:get_cwd(),
|
|
ok = file:set_cwd(zomp_dir()),
|
|
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
|
|
Meta = binary_to_term(MetaBin),
|
|
PackageID = maps:get(package_id, Meta),
|
|
TgzFile = namify_tgz(PackageID),
|
|
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
|
|
{KeyID, Signature} = maps:get(sig, Meta),
|
|
{ok, PubKey} = loadkey(public, KeyID),
|
|
ok =
|
|
case public_key:verify(TgzData, sha512, Signature, PubKey) of
|
|
true ->
|
|
ZrpPath = filename:join("zrp", namify_zrp(PackageID)),
|
|
erl_tar:create(ZrpPath, Files);
|
|
false ->
|
|
error_exit("Bad package signature: ~ts", [PackageFile], ?FILE, ?LINE)
|
|
end,
|
|
ok = file:set_cwd(CWD),
|
|
Message = "~ts is now locally available.",
|
|
ok = log(info, Message, [package_string(PackageID)]),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% Set dependency
|
|
|
|
-spec set_dep(package_id()) -> no_return().
|
|
%% @private
|
|
%% Set a specific dependency in the current project. If the project currently has a
|
|
%% dependency on the same package then the version of that dependency is updated to
|
|
%% reflect that in the PackageString argument. The AppString is permitted to be
|
|
%% incomplete. Incomplete elements of the VersionString (if included) will default to
|
|
%% the latest version available at the indicated level.
|
|
|
|
set_dep(PackageID = {_, _, {X, Y, Z}})
|
|
when is_integer(X), is_integer(Y), is_integer(Z) ->
|
|
Meta = read_meta(),
|
|
Deps = maps:get(deps, Meta),
|
|
case lists:member(PackageID, Deps) of
|
|
true ->
|
|
ok = log(info, "~ts is already a dependency", [package_string(PackageID)]),
|
|
halt(0);
|
|
false ->
|
|
set_dep(PackageID, Deps, Meta)
|
|
end;
|
|
set_dep({Realm, Name, {z, z, z}}) ->
|
|
Socket = connect_user(Realm),
|
|
{ok, Version} = query_latest(Socket, {Realm, Name}),
|
|
ok = disconnect(Socket),
|
|
set_dep({Realm, Name, Version});
|
|
set_dep({Realm, Name, Version}) ->
|
|
Socket = connect_user(Realm),
|
|
{ok, Latest} = query_latest(Socket, {Realm, Name, Version}),
|
|
ok = disconnect(Socket),
|
|
set_dep({Realm, Name, Latest}).
|
|
|
|
|
|
-spec set_dep(PackageID, Deps, Meta) -> no_return()
|
|
when PackageID :: package_id(),
|
|
Deps :: [package_id()],
|
|
Meta :: [term()].
|
|
%% @private
|
|
%% Given the PackageID, list of Deps and the current contents of the project Meta, add
|
|
%% or update Deps to include (or update) Deps to reflect a dependency on PackageID, if
|
|
%% such a dependency is not already present. Then write the project meta back to its
|
|
%% file and exit.
|
|
|
|
set_dep(PackageID = {Realm, Name, NewVersion}, Deps, Meta) ->
|
|
ExistingPackageIDs = fun ({R, N, _}) -> {R, N} == {Realm, Name} end,
|
|
NewDeps =
|
|
case lists:partition(ExistingPackageIDs, Deps) of
|
|
{[{Realm, Name, OldVersion}], Rest} ->
|
|
Message = "Updating dep ~ts to ~ts",
|
|
OldPackageString = package_string({Realm, Name, OldVersion}),
|
|
NewPackageString = package_string({Realm, Name, NewVersion}),
|
|
ok = log(info, Message, [OldPackageString, NewPackageString]),
|
|
[PackageID | Rest];
|
|
{[], Deps} ->
|
|
ok = log(info, "Adding dep ~ts", [package_string(PackageID)]),
|
|
[PackageID | Deps]
|
|
end,
|
|
NewMeta = maps:put(deps, NewDeps, Meta),
|
|
ok = write_meta(NewMeta),
|
|
halt(0).
|
|
|
|
|
|
-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 -> ensure_installed(Realm, Name, Version)
|
|
end.
|
|
|
|
|
|
-spec ensure_installed(Realm, Name, Version) -> Result | no_return()
|
|
when Realm :: realm(),
|
|
Name :: name(),
|
|
Version :: version(),
|
|
Result :: exact
|
|
| {ok, package_id()}
|
|
| not_found.
|
|
%% @private
|
|
%% Fetch and install the latest compatible version of the given package ID, whether
|
|
%% the version indicator is complete, partial or blank.
|
|
|
|
ensure_installed(Realm, Name, Version) ->
|
|
Socket = connect_user(Realm),
|
|
case query_latest(Socket, {Realm, Name, Version}) of
|
|
{ok, LatestVersion} ->
|
|
LatestID = {Realm, Name, LatestVersion},
|
|
ok = ensure_dep(Socket, LatestID),
|
|
ok = disconnect(Socket),
|
|
{ok, LatestID};
|
|
{error, bad_realm} ->
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
ok = log(warning, "Bad realm: ~ts.", [PackageString]),
|
|
halt(1);
|
|
{error, bad_package} ->
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
ok = log(warning, "Bad package: ~ts.", [PackageString]),
|
|
halt(1);
|
|
{error, bad_version} ->
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
ok = log(warning, "Bad version: ~s.", [PackageString]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec query_latest(Socket, Object) -> Result
|
|
when Socket :: gen_tcp:socket(),
|
|
Object :: package() | package_id(),
|
|
Result :: {ok, version()}
|
|
| {error, Reason},
|
|
Reason :: bad_realm
|
|
| bad_package
|
|
| bad_version.
|
|
%% @private
|
|
%% Queries the connected zomp node for the latest version of a package or package
|
|
%% version (complete or incomplete version number).
|
|
|
|
query_latest(Socket, {Realm, Name}) ->
|
|
ok = send(Socket, {latest, Realm, Name}),
|
|
receive
|
|
{tcp, Socket, Bin} -> binary_to_term(Bin)
|
|
after 5000 -> {error, timeout}
|
|
end;
|
|
query_latest(Socket, {Realm, Name, Version}) ->
|
|
ok = send(Socket, {latest, Realm, Name, Version}),
|
|
receive
|
|
{tcp, Socket, Bin} -> binary_to_term(Bin)
|
|
after 5000 -> {error, timeout}
|
|
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(PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
Pattern = PackageString ++ "*",
|
|
case filelib:wildcard(Pattern, "lib") of
|
|
[] ->
|
|
not_found;
|
|
[PackageString] ->
|
|
exact;
|
|
[Dir] ->
|
|
{_, _, Version} = package_id(Dir),
|
|
{ok, Version};
|
|
Dirs ->
|
|
Dir = lists:last(lists:sort(Dirs)),
|
|
{_, _, Version} = package_id(Dir),
|
|
{ok, Version}
|
|
end.
|
|
|
|
|
|
ensure_deps(Deps) ->
|
|
case scrub(Deps) of
|
|
[] ->
|
|
ok;
|
|
Needed ->
|
|
Partitioned = partition_by_realm(Needed),
|
|
EnsureDeps =
|
|
fun({Realm, Packages}) ->
|
|
Socket = connect_user(Realm),
|
|
ok = ensure_deps(Socket, Realm, Packages),
|
|
ok = disconnect(Socket),
|
|
log(info, "Disconnecting from realm: ~ts", [Realm])
|
|
end,
|
|
lists:foreach(EnsureDeps, Partitioned)
|
|
end.
|
|
|
|
|
|
partition_by_realm(PackageIDs) ->
|
|
PartitionMap = lists:foldl(fun partition_by_realm/2, #{}, PackageIDs),
|
|
maps:to_list(PartitionMap).
|
|
|
|
|
|
partition_by_realm({R, P, V}, M) ->
|
|
maps:update_with(R, fun(Ps) -> [{P, V} | Ps] end, [{P, V}], M).
|
|
|
|
|
|
ensure_deps(_, _, []) ->
|
|
ok;
|
|
ensure_deps(Socket, Realm, [{Name, Version} | Rest]) ->
|
|
ok = ensure_dep(Socket, {Realm, Name, Version}),
|
|
ensure_deps(Socket, Realm, Rest).
|
|
|
|
|
|
-spec ensure_dep(gen_tcp:socket(), package_id()) -> ok | no_return().
|
|
%% @private
|
|
%% Given an PackageID as an argument, check whether its package file exists in the
|
|
%% system cache, and if not download it. Should return `ok' whenever the file is
|
|
%% sourced, but exit with an error if it cannot locate or acquire the package.
|
|
|
|
ensure_dep(Socket, PackageID) ->
|
|
ZrpFile = filename:join("zrp", namify_zrp(PackageID)),
|
|
ok =
|
|
case filelib:is_regular(ZrpFile) of
|
|
true -> ok;
|
|
false -> fetch(Socket, PackageID)
|
|
end,
|
|
ok = install(PackageID),
|
|
build(PackageID).
|
|
|
|
|
|
|
|
%%% Set version
|
|
|
|
-spec set_version(VersionString) -> no_return()
|
|
when VersionString :: string().
|
|
%% @private
|
|
%% Convert a version string to a new version, sanitizing it in the process and returning
|
|
%% a reasonable error message on bad input.
|
|
|
|
set_version(VersionString) ->
|
|
NewVersion =
|
|
case string_to_version(VersionString) of
|
|
{_, _, z} ->
|
|
Message = "'set version' arguments must be complete, ex: 1.2.3",
|
|
ok = log(error, Message),
|
|
halt(22);
|
|
Version ->
|
|
Version
|
|
end,
|
|
update_version(NewVersion).
|
|
|
|
|
|
-spec update_version(Level) -> no_return()
|
|
when Level :: major
|
|
| minor
|
|
| patch
|
|
| VersionString,
|
|
VersionString :: string(). % Of the form "Major.Minor.Patch"
|
|
%% @private
|
|
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
|
|
%% or setting the version number to the one specified in VersionString.
|
|
%% This part of the procedure guards for the case when the zomp.meta file cannot be
|
|
%% read for some reason.
|
|
|
|
update_version(Arg) ->
|
|
Meta = read_meta(),
|
|
PackageID = maps:get(package_id, Meta),
|
|
update_version(Arg, PackageID, Meta).
|
|
|
|
|
|
-spec update_version(Level, PackageID, Meta) -> no_return()
|
|
when Level :: major
|
|
| minor
|
|
| patch
|
|
| version(),
|
|
PackageID :: package_id(),
|
|
Meta :: [{atom(), term()}].
|
|
%% @private
|
|
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
|
|
%% or setting the version number to the one specified in VersionString.
|
|
%% This part of the procedure does the actual update calculation, to include calling to
|
|
%% convert the VersionString (if it is passed) to a `version()' type and check its
|
|
%% validity (or halt if it is a bad string).
|
|
|
|
update_version(major, {Realm, Name, OldVersion = {Major, _, _}}, OldMeta) ->
|
|
NewVersion = {Major + 1, 0, 0},
|
|
update_version(Realm, Name, OldVersion, NewVersion, OldMeta);
|
|
update_version(minor, {Realm, Name, OldVersion = {Major, Minor, _}}, OldMeta) ->
|
|
NewVersion = {Major, Minor + 1, 0},
|
|
update_version(Realm, Name, OldVersion, NewVersion, OldMeta);
|
|
update_version(patch, {Realm, Name, OldVersion = {Major, Minor, Patch}}, OldMeta) ->
|
|
NewVersion = {Major, Minor, Patch + 1},
|
|
update_version(Realm, Name, OldVersion, NewVersion, OldMeta);
|
|
update_version(NewVersion, {Realm, Name, OldVersion}, OldMeta) ->
|
|
update_version(Realm, Name, OldVersion, NewVersion, OldMeta).
|
|
|
|
|
|
-spec update_version(Realm, Name, OldVersion, NewVersion, OldMeta) -> no_return()
|
|
when Realm :: realm(),
|
|
Name :: name(),
|
|
OldVersion :: version(),
|
|
NewVersion :: version(),
|
|
OldMeta :: [{atom(), term()}].
|
|
%% @private
|
|
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
|
|
%% or setting the version number to the one specified in VersionString.
|
|
%% This part of the procedure updates the meta and does the final write, if the write
|
|
%% turns out to be possible. If successful it will indicate to the user what was
|
|
%% changed.
|
|
|
|
update_version(Realm, Name, OldVersion, NewVersion, OldMeta) ->
|
|
PackageID = {Realm, Name, NewVersion},
|
|
NewMeta = maps:put(package_id, PackageID, OldMeta),
|
|
ok = write_meta(NewMeta),
|
|
ok = log(info,
|
|
"Version changed from ~s to ~s.",
|
|
[version_to_string(OldVersion), version_to_string(NewVersion)]),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% List Functions
|
|
|
|
-spec list_realms() -> no_return().
|
|
%% @private
|
|
%% List all currently configured realms. The definition of a "configured realm" is a
|
|
%% realm for which a .realm file exists in ~/.zomp/. The realms will be printed to
|
|
%% stdout and the program will exit.
|
|
|
|
list_realms() ->
|
|
Pattern = filename:join(zomp_dir(), "*.realm"),
|
|
RealmFiles = filelib:wildcard(Pattern),
|
|
Realms = [filename:basename(RF, ".realm") || RF <- RealmFiles],
|
|
ok = lists:foreach(fun(R) -> io:format("~ts~n", [R]) end, Realms),
|
|
halt(0).
|
|
|
|
|
|
-spec list_packages(realm()) -> no_return().
|
|
%% @private
|
|
%% Contact the indicated realm and query it for a list of registered packages and print
|
|
%% them to stdout.
|
|
|
|
list_packages(Realm) ->
|
|
Socket = connect_user(Realm),
|
|
ok = send(Socket, {list, Realm}),
|
|
case recv_or_die(Socket) of
|
|
{ok, []} ->
|
|
ok = log(info, "Realm ~tp has no packages available.", [Realm]),
|
|
halt(0);
|
|
{ok, Packages} ->
|
|
Print = fun({R, N}) -> io:format("~ts-~ts~n", [R, N]) end,
|
|
ok = lists:foreach(Print, Packages),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
-spec list_versions(package()) -> no_return().
|
|
%% @private
|
|
%% List the available versions of the package indicated. The user enters a string-form
|
|
%% package name (such as "otpr-zomp") and the return values will be full package strings
|
|
%% of the form "otpr-zomp-1.2.3", one per line printed to stdout.
|
|
|
|
list_versions(Package = {Realm, Name}) ->
|
|
ok = valid_package(Package),
|
|
Socket = connect_user(Realm),
|
|
ok = send(Socket, {list, Realm, Name}),
|
|
case recv_or_die(Socket) of
|
|
{ok, []} ->
|
|
Message = "Package ~ts-~ts has no versions available.",
|
|
ok = log(info, Message, [Realm, Name]),
|
|
halt(0);
|
|
{ok, Versions} ->
|
|
Print =
|
|
fun(Version) ->
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
io:format("~ts~n", [PackageString])
|
|
end,
|
|
ok = lists:foreach(Print, Versions),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
-spec list_pending(package()) -> no_return().
|
|
%% @private
|
|
%% List the versions of a package that are pending review. The package name is input by the
|
|
%% user as a string of the form "otpr-zomp" and the output is a list of full package IDs,
|
|
%% printed one per line to stdout (like "otpr-zomp-3.2.2").
|
|
|
|
list_pending(Package = {Realm, Name}) ->
|
|
ok = valid_package(Package),
|
|
Socket = connect_user(Realm),
|
|
ok = send(Socket, {pending, Package}),
|
|
case recv_or_die(Socket) of
|
|
{ok, []} ->
|
|
Message = "Package ~ts-~ts has no versions pending.",
|
|
ok = log(info, Message, [Realm, Name]),
|
|
halt(0);
|
|
{ok, Versions} ->
|
|
Print =
|
|
fun(Version) ->
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
io:format("~ts~n", [PackageString])
|
|
end,
|
|
ok = lists:foreach(Print, Versions),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
-spec list_resigns(realm()) -> no_return().
|
|
%% @private
|
|
%% List the package ids of all packages waiting in the resign queue for the given realm,
|
|
%% printed to stdout one per line.
|
|
|
|
list_resigns(Realm) ->
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {list_resigns, Realm}),
|
|
case recv_or_die(Socket) of
|
|
{ok, []} ->
|
|
Message = "No packages pending signature in ~tp.",
|
|
ok = log(info, Message, [Realm]),
|
|
halt(0);
|
|
{ok, PackageIDs} ->
|
|
Print =
|
|
fun(PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
io:format("~ts~n", [PackageString])
|
|
end,
|
|
ok = lists:foreach(Print, PackageIDs),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
-spec valid_package(package()) -> ok | no_return().
|
|
%% @private
|
|
%% Test whether a package() type is a valid value or not. If not, halt execution with
|
|
%% a non-zero error code, if so then return `ok'.
|
|
|
|
valid_package({Realm, Name}) ->
|
|
case {valid_lower0_9(Realm), valid_lower0_9(Name)} of
|
|
{true, true} ->
|
|
ok;
|
|
{false, true} ->
|
|
ok = log(error, "Invalid realm name: ~tp", [Realm]),
|
|
halt(1);
|
|
{true, false} ->
|
|
ok = log(error, "Invalid package name: ~tp", [Name]),
|
|
halt(1);
|
|
{false, false} ->
|
|
ok = log(error, "Invalid realm ~tp and package ~tp", [Realm, Name]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec string_to_package(string()) -> package() | no_return().
|
|
%% @private
|
|
%% Convert a string to a package() type if possible. If not then halt the system.
|
|
|
|
string_to_package(String) ->
|
|
case string:lexemes(String, "-") of
|
|
[Realm, Name] ->
|
|
Package = {Realm, Name},
|
|
ok = valid_package(Package),
|
|
Package;
|
|
_ ->
|
|
ok = log(error, "Bad package name."),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec valid_realm(realm()) -> ok | no_return().
|
|
%% @private
|
|
%% Test whether a realm name is a valid realm() type (that is, a lower0_9()) or not. If not,
|
|
%% halt execution with a non-zero error code, if so then return `ok'.
|
|
|
|
valid_realm(Realm) ->
|
|
case valid_lower0_9(Realm) of
|
|
true ->
|
|
ok;
|
|
false ->
|
|
ok = log(error, "Bad realm name."),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
|
|
%%% Add realm
|
|
|
|
-spec add_realm(Path) -> no_return()
|
|
when Path :: file:filename().
|
|
|
|
add_realm(Path) ->
|
|
case file:read_file(Path) of
|
|
{ok, Data} ->
|
|
Digest = crypto:hash(sha512, Data),
|
|
Text = integer_to_list(binary:decode_unsigned(Digest, big), 16),
|
|
ok = log(info, "SHA512 of ~ts: ~ts", [Path, Text]),
|
|
add_realm(Path, Data);
|
|
{error, enoent} ->
|
|
ok = log(warning, "FAILED: ~ts does not exist.", [Path]),
|
|
halt(1);
|
|
{error, eisdir} ->
|
|
ok = log(warning, "FAILED: ~ts is a directory, not a realm file.", [Path]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec add_realm(Path, Data) -> no_return()
|
|
when Path :: file:filename(),
|
|
Data :: binary().
|
|
|
|
add_realm(Path, Data) ->
|
|
case erl_tar:extract({binary, Data}, [compressed, {cwd, zomp_dir()}]) of
|
|
ok ->
|
|
{Realm, _} = string:take(filename:basename(Path), ".", true),
|
|
ok = log(info, "Realm ~ts is now visible to this system.", [Realm]),
|
|
halt(0);
|
|
{error, invalid_tar_checksum} ->
|
|
ok = log(warning, "FAILED: ~ts is not a valid realm file.", [Path]),
|
|
halt(1);
|
|
{error, eof} ->
|
|
ok = log(warning, "FAILED: ~ts is not a valid realm file.", [Path]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec add_package(PackageName) -> no_return()
|
|
when PackageName :: package().
|
|
|
|
add_package(PackageName) ->
|
|
ok = file:set_cwd(zomp_dir()),
|
|
case string:lexemes(PackageName, "-") of
|
|
[Realm, Name] ->
|
|
case {valid_lower0_9(Realm), valid_lower0_9(Name)} of
|
|
{true, true} ->
|
|
add_package(Realm, Name);
|
|
{false, true} ->
|
|
ok = log(warning, "Invalid realm name: ~tp", [Realm]),
|
|
halt(1);
|
|
{true, false} ->
|
|
ok = log(warning, "Invalid package name: ~tp", [Name]),
|
|
halt(1);
|
|
{false, false} ->
|
|
ok = log(warning, "Invalid realm and package names."),
|
|
halt(1)
|
|
end;
|
|
_ ->
|
|
ok = log(warning, "Name ~tp is not a valid package name.", [PackageName]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec add_package(Realm, Name) -> no_return()
|
|
when Realm :: realm(),
|
|
Name :: name().
|
|
%% @private
|
|
%% This sysop-only command can add a package to a realm operated by the caller.
|
|
|
|
add_package(Realm, Name) ->
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {add_package, {Realm, Name}}),
|
|
ok = recv_or_die(Socket),
|
|
ok = log(info, "\"~ts-~ts\" added successfully.", [Realm, Name]),
|
|
halt(0).
|
|
|
|
|
|
add_packager(Package, UserName) ->
|
|
ok = log(info, "Would add ~ts to packagers for ~160tp now.", [UserName, Package]),
|
|
halt(0).
|
|
|
|
|
|
add_maintainer(Package, UserName) ->
|
|
ok = log(info, "Would add ~ts to maintainer for ~160tp now.", [UserName, Package]),
|
|
halt(0).
|
|
|
|
|
|
review(PackageString) ->
|
|
PackageID = {Realm, _, _} = package_id(PackageString),
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {review, PackageID}),
|
|
{ok, ZrpBin} = recv_or_die(Socket),
|
|
ok = send(Socket, ok),
|
|
ok = disconnect(Socket),
|
|
{ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]),
|
|
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
|
|
Meta = binary_to_term(MetaBin, [safe]),
|
|
PackageID = maps:get(package_id, Meta),
|
|
{KeyID, Signature} = maps:get(sig, Meta),
|
|
{ok, PubKey} = loadkey(public, KeyID),
|
|
TgzFile = PackageString ++ ".tgz",
|
|
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
|
|
ok = verify(TgzData, Signature, PubKey),
|
|
ok =
|
|
case file:make_dir(PackageString) of
|
|
ok ->
|
|
log(info, "Will unpack to directory ./~ts", [PackageString]);
|
|
{error, Error} ->
|
|
Message = "Creating dir ./~ts failed with ~ts. Aborting.",
|
|
ok = log(error, Message, [PackageString, Error]),
|
|
halt(1)
|
|
end,
|
|
ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageString}]),
|
|
ok = log(info, "Unpacked and awaiting inspection."),
|
|
halt(0).
|
|
|
|
|
|
approve(PackageID = {Realm, _, _}) ->
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {approve, PackageID}),
|
|
ok = recv_or_die(Socket),
|
|
ok = log(info, "ok"),
|
|
halt(0).
|
|
|
|
|
|
reject(PackageID = {Realm, _, _}) ->
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {reject, PackageID}),
|
|
ok = recv_or_die(Socket),
|
|
ok = log(info, "ok"),
|
|
halt(0).
|
|
|
|
|
|
resign(PackageString) ->
|
|
PackageID = {Realm, _, _} = package_id(PackageString),
|
|
RealmConf = load_realm_conf(Realm),
|
|
{package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf),
|
|
KeySelection = [{K, {R, K}} || {R, K} <- [element(1, K) || K <- PackageKeys]],
|
|
PackageKeyID = select(KeySelection),
|
|
{ok, PackageKey} = loadkey(private, PackageKeyID),
|
|
Socket = connect_auth_or_die(Realm),
|
|
ok = send(Socket, {resign, PackageID}),
|
|
{ok, ZrpBin} = recv_or_die(Socket),
|
|
{ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]),
|
|
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
|
|
Meta = binary_to_term(MetaBin, [safe]),
|
|
PackageID = maps:get(package_id, Meta),
|
|
{KeyID, Signature} = maps:get(sig, Meta),
|
|
{ok, PubKey} = loadkey(public, KeyID),
|
|
TgzFile = PackageString ++ ".tgz",
|
|
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
|
|
ok = verify(TgzData, Signature, PubKey),
|
|
ReSignature = public_key:sign(TgzData, sha512, PackageKey),
|
|
FinalMeta = maps:put(sig, {PackageKeyID, ReSignature}, Meta),
|
|
NewMetaBin = term_to_binary(FinalMeta),
|
|
NewFiles = lists:keystore("zomp.meta", 1, Files, {"zomp.meta", NewMetaBin}),
|
|
ResignedZrp = PackageString ++ ".zrp.resign",
|
|
ok = erl_tar:create(ResignedZrp, NewFiles),
|
|
{ok, ResignedBin} = file:read_file(ResignedZrp),
|
|
ok = gen_tcp:send(Socket, ResignedBin),
|
|
ok = recv_or_die(Socket),
|
|
ok = file:delete(ResignedZrp),
|
|
ok = recv_or_die(Socket),
|
|
ok = disconnect(Socket),
|
|
ok = log(info, "Resigned ~ts", [PackageString]),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% Drop dependency
|
|
|
|
-spec drop_dep(package_id()) -> no_return().
|
|
%% @private
|
|
%% Remove the indicate dependency from the local project's zomp.meta record.
|
|
|
|
drop_dep(PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
Meta = read_meta(),
|
|
Deps = maps:get(deps, Meta),
|
|
case lists:member(PackageID, Deps) of
|
|
true ->
|
|
NewDeps = lists:delete(PackageID, Deps),
|
|
NewMeta = maps:put(deps, NewDeps, Meta),
|
|
ok = write_meta(NewMeta),
|
|
Message = "~ts removed from dependencies.",
|
|
ok = log(info, Message, [PackageString]),
|
|
halt(0);
|
|
false ->
|
|
ok = log(info, "~ts not found in dependencies.", [PackageString]),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
|
|
%%% Drop key
|
|
|
|
-spec drop_key(key_id()) -> no_return().
|
|
%% @private
|
|
%% Given a KeyID, remove the related public and private keys from the keystore, if they
|
|
%% exist. If not, exit with a message that no keys were found, but do not return an
|
|
%% error exit value (this instruction is idempotent if used in shell scripts).
|
|
|
|
drop_key({Realm, KeyName}) ->
|
|
ok = file:set_cwd(zomp_dir()),
|
|
Pattern = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".{key,pub}.der"]),
|
|
case filelib:wildcard(Pattern) of
|
|
[] ->
|
|
ok = log(warning, "Key ~ts/~ts not found", [Realm, KeyName]),
|
|
halt(0);
|
|
Files ->
|
|
ok = lists:foreach(fun file:delete/1, Files),
|
|
ok = log(info, "Keyset ~ts/~ts removed", [Realm, KeyName]),
|
|
halt(0)
|
|
end.
|
|
|
|
|
|
%%% Drop realm
|
|
|
|
-spec drop_realm(realm()) -> no_return().
|
|
|
|
drop_realm(Realm) ->
|
|
ok = file:set_cwd(zomp_dir()),
|
|
RealmConf = realm_conf(Realm),
|
|
case filelib:is_regular(RealmConf) of
|
|
true ->
|
|
Message =
|
|
"~n"
|
|
" WARNING: Are you SURE you want to remove realm ~ts?~n"
|
|
" (Only \"Y\" will confirm this action.)~n",
|
|
ok = io:format(Message, [Realm]),
|
|
case get_input() of
|
|
"Y" ->
|
|
ok = file:delete(RealmConf),
|
|
ok = drop_prime(Realm),
|
|
ok = clear_keys(Realm),
|
|
ok = log(info, "All traces of realm ~ts have been removed."),
|
|
halt(0);
|
|
_ ->
|
|
ok = log(info, "Aborting."),
|
|
halt(0)
|
|
end;
|
|
false ->
|
|
ok = log(warning, "Realm conf ~ts not found.", [RealmConf]),
|
|
clear_keys(Realm)
|
|
end.
|
|
|
|
-spec drop_prime(realm()) -> ok.
|
|
|
|
drop_prime(Realm) ->
|
|
Path = "zomp.conf",
|
|
case file:consult(Path) of
|
|
{ok, Conf} ->
|
|
{managed, Primes} = lists:keyfind(managed, 1, Conf),
|
|
NewPrimes = lists:delete(Realm, Primes),
|
|
NewConf = lists:keystore(managed, 1, Primes, {managed, NewPrimes}),
|
|
ok = write_terms(Path, NewConf),
|
|
log(info, "Ensuring ~ts is not a prime in ~ts", [Realm, Path]);
|
|
{error, enoent} ->
|
|
ok
|
|
end.
|
|
|
|
|
|
-spec clear_keys(realm()) -> ok.
|
|
|
|
clear_keys(Realm) ->
|
|
KeyDir = filename:join([zomp_dir(), "key", Realm]),
|
|
case filelib:is_dir(KeyDir) of
|
|
true ->
|
|
ok = log(info, "Wiping key dir ~ts", [KeyDir]),
|
|
Keys = filelib:wildcard(KeyDir ++ "/**"),
|
|
Delete = fun(K) -> file:delete(K) end,
|
|
ok = lists:foreach(Delete, Keys),
|
|
ok = file:del_dir(KeyDir),
|
|
log(info, "Done!");
|
|
false ->
|
|
log(warning, "Keydir ~ts not found", [KeyDir])
|
|
end.
|
|
|
|
|
|
|
|
%%% Update version
|
|
|
|
-spec verup(Level) -> no_return()
|
|
when Level :: string().
|
|
%% @private
|
|
%% Convert input string arguments to acceptable atoms for use in update_version/1.
|
|
|
|
verup("major") -> update_version(major);
|
|
verup("minor") -> update_version(minor);
|
|
verup("patch") -> update_version(patch);
|
|
verup(_) -> usage_exit(22).
|
|
|
|
|
|
|
|
%%% Run local project
|
|
|
|
-spec run_local(Args) -> no_return()
|
|
when Args :: [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(Args) ->
|
|
Meta = read_meta(),
|
|
{Realm, Name, Version} = maps:get(package_id, Meta),
|
|
Type = maps:get(type, Meta),
|
|
Deps = maps:get(deps, Meta),
|
|
ok = build(),
|
|
{ok, Dir} = file:get_cwd(),
|
|
ok = file:set_cwd(zomp_dir()),
|
|
State = #s{realm = Realm,
|
|
name = Name,
|
|
version = Version,
|
|
type = Type,
|
|
deps = Deps,
|
|
dir = Dir},
|
|
ok = ensure_deps(Deps),
|
|
ok = file:set_cwd(Dir),
|
|
execute(State, Args).
|
|
|
|
|
|
-spec execute(State, Args) -> no_return()
|
|
when State :: state(),
|
|
Args :: [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(State = #s{type = app, realm = Realm, name = Name, version = Version}, Args) ->
|
|
true = register(zx, self()),
|
|
ok = inets:start(),
|
|
ok = log(info, "Starting ~ts", [package_string({Realm, Name, Version})]),
|
|
AppMod = list_to_atom(Name),
|
|
{ok, Apps} = application:ensure_all_started(AppMod),
|
|
ok = log(info, "Started, ~tp", [Apps]),
|
|
ok = pass_argv(AppMod, Args),
|
|
exec_wait(State);
|
|
execute(#s{type = lib, realm = Realm, name = Name, version = Version}, _) ->
|
|
Message = "Lib ~ts is available on the system, but is not a standalone app.",
|
|
PackageString = package_string({Realm, Name, Version}),
|
|
ok = log(info, Message, [PackageString]),
|
|
halt(0).
|
|
|
|
|
|
-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.
|
|
|
|
|
|
|
|
%%% Package generation
|
|
|
|
-spec package(TargetDir) -> no_return()
|
|
when TargetDir :: file:filename().
|
|
%% @private
|
|
%% Turn a target project directory into a package, prompting the user for appropriate
|
|
%% key selection or generation actions along the way.
|
|
|
|
package(TargetDir) ->
|
|
ok = log(info, "Packaging ~ts", [TargetDir]),
|
|
Meta = read_meta(TargetDir),
|
|
{Realm, _, _} = maps:get(package_id, Meta),
|
|
KeyDir = filename:join([zomp_dir(), "key", Realm]),
|
|
ok = force_dir(KeyDir),
|
|
Pattern = KeyDir ++ "/*.key.der",
|
|
case [filename:basename(F, ".key.der") || F <- filelib:wildcard(Pattern)] of
|
|
[] ->
|
|
ok = log(info, "Need to generate key"),
|
|
KeyID = prompt_keygen(),
|
|
{ok, _, _} = generate_rsa(KeyID),
|
|
package(KeyID, TargetDir);
|
|
[KeyName] ->
|
|
KeyID = {Realm, KeyName},
|
|
ok = log(info, "Using key: ~ts/~ts", [Realm, KeyName]),
|
|
package(KeyID, TargetDir);
|
|
KeyNames ->
|
|
KeyName = select_string(KeyNames),
|
|
package({Realm, KeyName}, TargetDir)
|
|
end.
|
|
|
|
|
|
-spec package(KeyID, TargetDir) -> no_return()
|
|
when KeyID :: key_id(),
|
|
TargetDir :: file:filename().
|
|
%% @private
|
|
%% Accept a KeyPrefix for signing and a TargetDir containing a project to package and
|
|
%% build a zrp package file ready to be submitted to a repository.
|
|
|
|
package(KeyID, TargetDir) ->
|
|
Meta = read_meta(TargetDir),
|
|
PackageID = maps:get(package_id, Meta),
|
|
true = element(1, PackageID) == element(1, KeyID),
|
|
PackageString = package_string(PackageID),
|
|
ZrpFile = PackageString ++ ".zrp",
|
|
TgzFile = PackageString ++ ".tgz",
|
|
ok = halt_if_exists(ZrpFile),
|
|
ok = remove_binaries(TargetDir),
|
|
{ok, Everything} = file:list_dir(TargetDir),
|
|
DotFiles = filelib:wildcard(".*", TargetDir),
|
|
Ignores = ["lib" | DotFiles],
|
|
Targets = lists:subtract(Everything, Ignores),
|
|
{ok, CWD} = file:get_cwd(),
|
|
ok = file:set_cwd(TargetDir),
|
|
ok = build(),
|
|
Modules = [filename:basename(M, ".beam") || M <- filelib:wildcard("*.beam", "ebin")],
|
|
ok = remove_binaries("."),
|
|
ok = erl_tar:create(filename:join(CWD, TgzFile), Targets, [compressed]),
|
|
ok = file:set_cwd(CWD),
|
|
{ok, Key} = loadkey(private, KeyID),
|
|
{ok, TgzBin} = file:read_file(TgzFile),
|
|
Sig = public_key:sign(TgzBin, sha512, Key),
|
|
Add = fun({K, V}, M) -> maps:put(K, V, M) end,
|
|
FinalMeta = lists:foldl(Add, Meta, [{modules, Modules}, {sig, {KeyID, Sig}}]),
|
|
ok = file:write_file("zomp.meta", term_to_binary(FinalMeta)),
|
|
ok = erl_tar:create(ZrpFile, ["zomp.meta", TgzFile]),
|
|
ok = file:delete(TgzFile),
|
|
ok = file:delete("zomp.meta"),
|
|
ok = log(info, "Wrote archive ~ts", [ZrpFile]),
|
|
halt(0).
|
|
|
|
|
|
-spec remove_binaries(TargetDir) -> ok
|
|
when TargetDir :: file:filename().
|
|
%% @private
|
|
%% Procedure to delete all .beam and .ez files from a given directory starting at
|
|
%% TargetDir. Called as part of the pre-packaging sanitization procedure.
|
|
|
|
remove_binaries(TargetDir) ->
|
|
Beams = filelib:wildcard("**/*.{beam,ez}", TargetDir),
|
|
case [filename:join(TargetDir, Beam) || Beam <- Beams] of
|
|
[] ->
|
|
ok;
|
|
ToDelete ->
|
|
ok = log(info, "Removing: ~tp", [ToDelete]),
|
|
lists:foreach(fun file:delete/1, ToDelete)
|
|
end.
|
|
|
|
|
|
|
|
%%% Package submission
|
|
|
|
-spec submit(PackageFile) -> no_return()
|
|
when PackageFile :: file:filename().
|
|
%% @private
|
|
%% Submit a package to the appropriate "prime" server for the given realm.
|
|
|
|
submit(PackageFile) ->
|
|
Files = extract_zrp(PackageFile),
|
|
{ok, PackageData} = file:read_file(PackageFile),
|
|
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
|
|
Meta = binary_to_term(MetaBin),
|
|
{Realm, Package, Version} = maps:get(package_id, Meta),
|
|
{ok, Socket} = connect_auth(Realm),
|
|
ok = send(Socket, {submit, {Realm, Package, Version}}),
|
|
ok = recv_or_die(Socket),
|
|
ok = gen_tcp:send(Socket, PackageData),
|
|
ok = log(info, "Done sending contents of ~tp", [PackageFile]),
|
|
Outcome = recv_or_die(Socket),
|
|
log(info, "Response: ~tp", [Outcome]),
|
|
ok = disconnect(Socket),
|
|
halt(0).
|
|
|
|
|
|
-spec send(Socket, Message) -> ok
|
|
when Socket :: gen_tcp:socket(),
|
|
Message :: term().
|
|
%% @private
|
|
%% Wrapper for the procedure necessary to send an internal message over the wire.
|
|
|
|
send(Socket, Message) ->
|
|
Bin = term_to_binary(Message),
|
|
gen_tcp:send(Socket, Bin).
|
|
|
|
|
|
-spec recv_or_die(Socket) -> Result | no_return()
|
|
when Socket :: gen_tcp:socket(),
|
|
Result :: ok | {ok, term()}.
|
|
|
|
recv_or_die(Socket) ->
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin) of
|
|
ok ->
|
|
ok;
|
|
{ok, Response} ->
|
|
{ok, Response};
|
|
{error, bad_realm} ->
|
|
ok = log(warning, "No such realm at the connected node."),
|
|
halt(1);
|
|
{error, bad_package} ->
|
|
ok = log(warning, "No such package."),
|
|
halt(1);
|
|
{error, bad_version} ->
|
|
ok = log(warning, "No such version."),
|
|
halt(1);
|
|
{error, not_in_queue} ->
|
|
ok = log(warning, "Version is not queued."),
|
|
halt(1);
|
|
{error, bad_message} ->
|
|
ok = log(error, "Oh noes! zx sent an illegal message!"),
|
|
halt(1);
|
|
{error, already_exists} ->
|
|
ok = log(warning, "Server refuses: already_exists"),
|
|
halt(1);
|
|
{error, {system, Reason}} ->
|
|
Message = "Node experienced system error: ~160tp",
|
|
ok = log(warning, Message, [{error, Reason}]),
|
|
halt(1);
|
|
Unexpected ->
|
|
ok = log(warning, "Unexpected message: ~tp", [Unexpected]),
|
|
halt(1)
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
ok = log(warning, "Lost connection to node unexpectedly."),
|
|
halt(1)
|
|
after 5000 ->
|
|
ok = log(warning, "Node timed out."),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec halt_on_unexpected_close() -> no_return().
|
|
|
|
halt_on_unexpected_close() ->
|
|
ok = log(warning, "Socket closed unexpectedly."),
|
|
halt(1).
|
|
|
|
|
|
-spec connect_user(realm()) -> gen_tcp:socket() | no_return().
|
|
%% @private
|
|
%% Connect to a given realm, whatever method is required.
|
|
|
|
connect_user(Realm) ->
|
|
ok = log(info, "Connecting to realm ~ts...", [Realm]),
|
|
Hosts =
|
|
case file:consult(hosts_cache_file(Realm)) of
|
|
{ok, Cached} -> Cached;
|
|
{error, enoent} -> []
|
|
end,
|
|
connect_user(Realm, Hosts).
|
|
|
|
|
|
-spec connect_user(realm(), [host()]) -> gen_tcp:socket() | no_return().
|
|
%% @private
|
|
%% Try to connect to a subordinate host, if there are none then connect to prime.
|
|
|
|
connect_user(Realm, []) ->
|
|
{Host, Port} = get_prime(Realm),
|
|
HostString =
|
|
case io_lib:printable_unicode_list(Host) of
|
|
true -> Host;
|
|
false -> inet:ntoa(Host)
|
|
end,
|
|
ok = log(info, "Trying prime at ~ts:~160tp", [HostString, Port]),
|
|
case gen_tcp:connect(Host, Port, connect_options(), 5000) of
|
|
{ok, Socket} ->
|
|
confirm_user(Realm, Socket, []);
|
|
{error, Error} ->
|
|
ok = log(warning, "Connection problem with prime: ~tp", [Error]),
|
|
halt(0)
|
|
end;
|
|
connect_user(Realm, Hosts = [Node = {Host, Port} | Rest]) ->
|
|
ok = log(info, "Trying node at ~ts:~tp", [inet:ntoa(Host), Port]),
|
|
case gen_tcp:connect(Host, Port, connect_options(), 5000) of
|
|
{ok, Socket} ->
|
|
confirm_user(Realm, Socket, Hosts);
|
|
{error, Error} ->
|
|
ok = log(warning, "Connection problem with ~tp: ~tp", [Node, Error]),
|
|
connect_user(Realm, Rest)
|
|
end.
|
|
|
|
|
|
-spec confirm_user(Realm, Socket, Hosts) -> Socket | no_return()
|
|
when Realm :: realm(),
|
|
Socket :: gen_tcp:socket(),
|
|
Hosts :: [host()].
|
|
%% @private
|
|
%% Confirm the zomp node can handle "OTPR USER 1" and is accepting connections or try
|
|
%% another node.
|
|
|
|
confirm_user(Realm, Socket, Hosts) ->
|
|
{ok, {Addr, Port}} = inet:peername(Socket),
|
|
Host = inet:ntoa(Addr),
|
|
ok = gen_tcp:send(Socket, <<"OTPR USER 1">>),
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin) of
|
|
ok ->
|
|
ok = log(info, "Connected to ~ts:~p", [Host, Port]),
|
|
confirm_serial(Realm, Socket, Hosts);
|
|
{redirect, Next} ->
|
|
ok = log(info, "Redirected..."),
|
|
ok = disconnect(Socket),
|
|
connect_user(Realm, Next ++ Hosts)
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 5000 ->
|
|
ok = log(warning, "Host ~ts:~p timed out.", [Host, Port]),
|
|
ok = disconnect(Socket),
|
|
connect_user(Realm, Hosts)
|
|
end.
|
|
|
|
|
|
-spec confirm_serial(Realm, Socket, Hosts) -> Socket | no_return()
|
|
when Realm :: realm(),
|
|
Socket :: gen_tcp:socket(),
|
|
Hosts :: [host()].
|
|
%% @private
|
|
%% Confirm that the connected host has a valid serial for the realm zx is trying to
|
|
%% reach, and if not retry on another node.
|
|
|
|
confirm_serial(Realm, Socket, Hosts) ->
|
|
SerialFile = filename:join(zomp_dir(), "realm.serials"),
|
|
Serials =
|
|
case file:consult(SerialFile) of
|
|
{ok, Ss} -> Ss;
|
|
{error, enoent} -> []
|
|
end,
|
|
Serial =
|
|
case lists:keyfind(Realm, 1, Serials) of
|
|
false -> 0;
|
|
{Realm, S} -> S
|
|
end,
|
|
ok = send(Socket, {latest, Realm}),
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin) of
|
|
{ok, Serial} ->
|
|
ok = log(info, "Node's serial same as ours."),
|
|
Socket;
|
|
{ok, Current} when Current > Serial ->
|
|
ok = log(info, "Node's serial newer than ours. Storing."),
|
|
NewSerials = lists:keystore(Realm, 1, Serials, {Realm, Current}),
|
|
{ok, Host} = inet:peername(Socket),
|
|
ok = write_terms(hosts_cache_file(Realm), [Host | Hosts]),
|
|
ok = write_terms(SerialFile, NewSerials),
|
|
Socket;
|
|
{ok, Current} when Current < Serial ->
|
|
log(info, "Our serial: ~tp, node serial: ~tp.", [Serial, Current]),
|
|
ok = log(info, "Node's serial older than ours. Trying another."),
|
|
ok = disconnect(Socket),
|
|
connect_user(Realm, Hosts);
|
|
{error, bad_realm} ->
|
|
ok = log(info, "Node is no longer serving realm. Trying another."),
|
|
ok = disconnect(Socket),
|
|
connect_user(Realm, Hosts)
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 5000 ->
|
|
ok = log(info, "Host timed out on confirm_serial. Trying another."),
|
|
ok = disconnect(Socket),
|
|
connect_user(Realm, Hosts)
|
|
end.
|
|
|
|
|
|
-spec connect_auth_or_die(realm()) -> gen_tcp:socket() | no_return().
|
|
|
|
connect_auth_or_die(Realm) ->
|
|
case connect_auth(Realm) of
|
|
{ok, Socket} ->
|
|
Socket;
|
|
Error ->
|
|
M1 = "Connection failed to realm prime with ~160tp.",
|
|
ok = log(warning, M1, [Error]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec connect_auth(Realm) -> Result
|
|
when Realm :: realm(),
|
|
Result :: {ok, gen_tcp:socket()}
|
|
| {error, Reason :: term()}.
|
|
%% @private
|
|
%% Connect to one of the servers in the realm constellation.
|
|
|
|
connect_auth(Realm) ->
|
|
RealmConf = load_realm_conf(Realm),
|
|
{User, KeyID, Key} = prep_auth(Realm, RealmConf),
|
|
{prime, {Host, Port}} = lists:keyfind(prime, 1, RealmConf),
|
|
case gen_tcp:connect(Host, Port, connect_options(), 5000) of
|
|
{ok, Socket} ->
|
|
ok = log(info, "Connected to ~tp prime.", [Realm]),
|
|
connect_auth(Socket, Realm, User, KeyID, Key);
|
|
Error = {error, E} ->
|
|
ok = log(warning, "Connection problem: ~tp", [E]),
|
|
{error, Error}
|
|
end.
|
|
|
|
|
|
-spec connect_auth(Socket, Realm, User, KeyID, Key) -> Result
|
|
when Socket :: gen_tcp:socket(),
|
|
Realm :: realm(),
|
|
User :: user(),
|
|
KeyID :: key_id(),
|
|
Key :: term(),
|
|
Result :: {ok, gen_tcp:socket()}
|
|
| {error, timeout}.
|
|
%% @private
|
|
%% Send a protocol ID string to notify the server what we're up to, disconnect
|
|
%% if it does not return an "OK" response within 5 seconds.
|
|
|
|
connect_auth(Socket, Realm, User, KeyID, Key) ->
|
|
ok = gen_tcp:send(Socket, <<"OTPR AUTH 1">>),
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
ok = binary_to_term(Bin, [safe]),
|
|
confirm_auth(Socket, Realm, User, KeyID, Key);
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 5000 ->
|
|
ok = log(warning, "Host realm ~160tp prime timed out.", [Realm]),
|
|
{error, auth_timeout}
|
|
end.
|
|
|
|
|
|
confirm_auth(Socket, Realm, User, KeyID, Key) ->
|
|
ok = send(Socket, {User, KeyID}),
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin, [safe]) of
|
|
{sign, Blob} ->
|
|
Sig = public_key:sign(Blob, sha512, Key),
|
|
ok = send(Socket, {signed, Sig}),
|
|
confirm_auth(Socket);
|
|
{error, not_prime} ->
|
|
M1 = "Connected node is not prime for realm ~160tp",
|
|
ok = log(warning, M1, [Realm]),
|
|
ok = disconnect(Socket),
|
|
{error, not_prime};
|
|
{error, bad_user} ->
|
|
M2 = "Bad user record ~160tp",
|
|
ok = log(warning, M2, [User]),
|
|
ok = disconnect(Socket),
|
|
{error, bad_user};
|
|
{error, unauthorized_key} ->
|
|
M3 = "Unauthorized user key ~160tp",
|
|
ok = log(warning, M3, [KeyID]),
|
|
ok = disconnect(Socket),
|
|
{error, unauthorized_key};
|
|
{error, Reason} ->
|
|
Message = "Could not begin auth exchange. Failed with ~160tp",
|
|
ok = log(warning, Message, [Reason]),
|
|
ok = disconnect(Socket),
|
|
{error, Reason}
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 5000 ->
|
|
ok = log(warning, "Host realm ~tp prime timed out.", [Realm]),
|
|
{error, auth_timeout}
|
|
end.
|
|
|
|
|
|
confirm_auth(Socket) ->
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin, [safe]) of
|
|
ok -> {ok, Socket};
|
|
Other -> {error, Other}
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 5000 ->
|
|
{error, timeout}
|
|
end.
|
|
|
|
|
|
-spec prep_auth(Realm, RealmConf) -> {User, KeyID, Key} | no_return()
|
|
when Realm :: realm(),
|
|
RealmConf :: [term()],
|
|
User :: user(),
|
|
KeyID :: key_id(),
|
|
Key :: term().
|
|
%% @private
|
|
%% Loads the appropriate User, KeyID and reads in a registered key for use in
|
|
%% connect_auth/4.
|
|
|
|
prep_auth(Realm, RealmConf) ->
|
|
UsersFile = filename:join(zomp_dir(), "zomp.users"),
|
|
Users =
|
|
case file:consult(UsersFile) of
|
|
{ok, U} ->
|
|
U;
|
|
{error, enoent} ->
|
|
ok = log(warning, "You do not have any users configured."),
|
|
halt(1)
|
|
end,
|
|
{User, KeyIDs} =
|
|
case lists:keyfind(Realm, 1, Users) of
|
|
{Realm, UserName, []} ->
|
|
W = "User ~tp does not have any keys registered for realm ~tp.",
|
|
ok = log(warning, W, [UserName, Realm]),
|
|
ok = log(info, "Contact the following sysop(s) to register a key:"),
|
|
{sysops, Sysops} = lists:keyfind(sysops, 1, RealmConf),
|
|
PrintContact =
|
|
fun({_, _, Email, Name, _, _}) ->
|
|
log(info, "Sysop: ~ts Email: ~ts", [Name, Email])
|
|
end,
|
|
ok = lists:foreach(PrintContact, Sysops),
|
|
halt(1);
|
|
{Realm, UserName, KeyNames} ->
|
|
KIDs = [{Realm, KeyName} || KeyName <- KeyNames],
|
|
{{Realm, UserName}, KIDs};
|
|
false ->
|
|
Message = "You are not a user of the given realm: ~160tp.",
|
|
ok = log(warning, Message, [Realm]),
|
|
halt(1)
|
|
end,
|
|
KeyID = hd(KeyIDs),
|
|
true = ensure_keypair(KeyID),
|
|
{ok, Key} = loadkey(private, KeyID),
|
|
{User, KeyID, Key}.
|
|
|
|
|
|
-spec connect_options() -> [gen_tcp:connect_option()].
|
|
%% @private
|
|
%% Hide away the default options used for TCP connections.
|
|
|
|
connect_options() ->
|
|
[{packet, 4}, {mode, binary}, {active, true}].
|
|
|
|
|
|
-spec get_prime(realm()) -> host().
|
|
%% @private
|
|
%% Check the given Realm's config file for the current prime node and return it.
|
|
|
|
get_prime(Realm) ->
|
|
RealmMeta = realm_meta(Realm),
|
|
{prime, Prime} = lists:keyfind(prime, 1, RealmMeta),
|
|
Prime.
|
|
|
|
|
|
-spec hosts_cache_file(realm()) -> file:filename().
|
|
%% @private
|
|
%% Given a Realm name, construct a realm's .hosts filename and return it.
|
|
|
|
hosts_cache_file(Realm) ->
|
|
filename:join(zomp_dir(), Realm ++ ".hosts").
|
|
|
|
|
|
-spec disconnect(gen_tcp:socket()) -> ok.
|
|
%% @private
|
|
%% Gracefully shut down a socket, logging (but sidestepping) the case when the socket
|
|
%% has already been closed by the other side.
|
|
|
|
disconnect(Socket) ->
|
|
case gen_tcp:shutdown(Socket, read_write) of
|
|
ok ->
|
|
log(info, "Disconnected from ~tp", [Socket]);
|
|
{error, Error} ->
|
|
Message = "Shutdown connection ~p failed with: ~p",
|
|
log(warning, Message, [Socket, Error])
|
|
end.
|
|
|
|
|
|
-spec ensure_keypair(key_id()) -> true | no_return().
|
|
%% @private
|
|
%% Check if both the public and private key based on KeyID exists.
|
|
|
|
ensure_keypair(KeyID = {Realm, KeyName}) ->
|
|
case {have_public_key(KeyID), have_private_key(KeyID)} of
|
|
{true, true} ->
|
|
true;
|
|
{false, true} ->
|
|
Message = "Public key for ~tp/~tp cannot be found",
|
|
ok = log(error, Message, [Realm, KeyName]),
|
|
halt(1);
|
|
{true, false} ->
|
|
Message = "Private key for ~tp/~tp cannot be found",
|
|
ok = log(error, Message, [Realm, KeyName]),
|
|
halt(1);
|
|
{false, false} ->
|
|
Message = "Key pair for ~tp/~tp cannot be found",
|
|
ok = log(error, Message, [Realm, KeyName]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec have_public_key(key_id()) -> boolean().
|
|
%% @private
|
|
%% Determine whether the public key indicated by KeyID is in the keystore.
|
|
|
|
have_public_key({Realm, KeyName}) ->
|
|
PublicKeyFile = KeyName ++ ".pub.der",
|
|
PublicKeyPath = filename:join([zomp_dir(), "key", Realm, PublicKeyFile]),
|
|
filelib:is_regular(PublicKeyPath).
|
|
|
|
|
|
-spec have_private_key(key_id()) -> boolean().
|
|
%% @private
|
|
%% Determine whether the private key indicated by KeyID is in the keystore.
|
|
|
|
have_private_key({Realm, KeyName}) ->
|
|
PrivateKeyFile = KeyName ++ ".key.der",
|
|
PrivateKeyPath = filename:join([zomp_dir(), "key", Realm, PrivateKeyFile]),
|
|
filelib:is_regular(PrivateKeyPath).
|
|
|
|
|
|
-spec realm_meta(Realm) -> Meta | no_return()
|
|
when Realm :: string(),
|
|
Meta :: [{atom(), term()}].
|
|
%% @private
|
|
%% Given a realm name, try to locate and read the realm's configuration file if it
|
|
%% exists, exiting with an appropriate error message if there is a problem reading
|
|
%% the file.
|
|
|
|
realm_meta(Realm) ->
|
|
RealmFile = filename:join(zomp_dir(), Realm ++ ".realm"),
|
|
case file:consult(RealmFile) of
|
|
{ok, Meta} ->
|
|
Meta;
|
|
{error, enoent} ->
|
|
ok = log(error, "No realm file for ~ts", [Realm]),
|
|
halt(1);
|
|
Error ->
|
|
Message = "Open realm file ~ts failed with ~ts",
|
|
error_exit(Message, [RealmFile, Error], ?FILE, ?LINE)
|
|
end.
|
|
|
|
|
|
|
|
%%% Key generation
|
|
|
|
-spec prompt_keygen() -> key_id().
|
|
%% @private
|
|
%% Prompt the user for a valid KeyPrefix to use for naming a new RSA keypair.
|
|
|
|
prompt_keygen() ->
|
|
Message =
|
|
"~n Enter a name for your new keys.~n~n"
|
|
" Valid names must start with a lower-case letter, and can include~n"
|
|
" only lower-case letters, numbers, and periods, but no series of~n"
|
|
" consecutive periods. (That is: [a-z0-9\\.])~n~n"
|
|
" To designate the key as realm-specific, enter the realm name and~n"
|
|
" key name separated by a space.~n~n"
|
|
" Example: some.realm my.key~n",
|
|
ok = io:format(Message),
|
|
Input = get_input(),
|
|
{Realm, KeyName} =
|
|
case string:lexemes(Input, " ") of
|
|
[R, K] -> {R, K};
|
|
[K] -> {"otpr", K}
|
|
end,
|
|
case {valid_lower0_9(Realm), valid_label(KeyName)} of
|
|
{true, true} ->
|
|
{Realm, KeyName};
|
|
{false, true} ->
|
|
ok = io:format("Bad realm name ~tp. Try again.~n", [Realm]),
|
|
prompt_keygen();
|
|
{true, false} ->
|
|
ok = io:format("Bad key name ~tp. Try again.~n", [KeyName]),
|
|
prompt_keygen();
|
|
{false, false} ->
|
|
ok = io:format("NUTS! Both key and realm names are illegal. Try again.~n"),
|
|
prompt_keygen()
|
|
end.
|
|
|
|
|
|
-spec create_keypair() -> no_return().
|
|
%% @private
|
|
%% Execute the key generation procedure for 16k RSA keys once and then terminate.
|
|
|
|
create_keypair() ->
|
|
ok = file:set_cwd(zomp_dir()),
|
|
KeyID = prompt_keygen(),
|
|
case generate_rsa(KeyID) of
|
|
{ok, _, _} -> halt(0);
|
|
Error -> error_exit("create_keypair/0 error: ~tp", [Error], ?FILE, ?LINE)
|
|
end.
|
|
|
|
|
|
-spec generate_rsa(KeyID) -> Result
|
|
when KeyID :: key_id(),
|
|
Result :: {ok, KeyFile, PubFile}
|
|
| {error, keygen_fail},
|
|
KeyFile :: file:filename(),
|
|
PubFile :: file:filename().
|
|
%% @private
|
|
%% Generate an RSA keypair and write them in der format to the current directory, using
|
|
%% filenames derived from Prefix.
|
|
%% NOTE: The current version of this command is likely to only work on a unix system.
|
|
|
|
generate_rsa({Realm, KeyName}) ->
|
|
KeyDir = filename:join([zomp_dir(), "key", Realm]),
|
|
ok = force_dir(KeyDir),
|
|
PemFile = filename:join(KeyDir, KeyName ++ ".pub.pem"),
|
|
KeyFile = filename:join(KeyDir, KeyName ++ ".key.der"),
|
|
PubFile = filename:join(KeyDir, KeyName ++ ".pub.der"),
|
|
ok = lists:foreach(fun halt_if_exists/1, [PemFile, KeyFile, PubFile]),
|
|
ok = log(info, "Generating ~p and ~p. Please be patient...", [KeyFile, PubFile]),
|
|
ok = gen_p_key(KeyFile),
|
|
ok = der_to_pem(KeyFile, PemFile),
|
|
{ok, PemBin} = file:read_file(PemFile),
|
|
[PemData] = public_key:pem_decode(PemBin),
|
|
Pub = public_key:pem_entry_decode(PemData),
|
|
PubDer = public_key:der_encode('RSAPublicKey', Pub),
|
|
ok = file:write_file(PubFile, PubDer),
|
|
case check_key(KeyFile, PubFile) of
|
|
true ->
|
|
ok = file:delete(PemFile),
|
|
ok = log(info, "~ts and ~ts agree", [KeyFile, PubFile]),
|
|
ok = log(info, "Wrote private key to: ~ts.", [KeyFile]),
|
|
ok = log(info, "Wrote public key to: ~ts.", [PubFile]),
|
|
{ok, KeyFile, PubFile};
|
|
false ->
|
|
ok = lists:foreach(fun file:delete/1, [PemFile, KeyFile, PubFile]),
|
|
ok = log(error, "Something has gone wrong."),
|
|
{error, keygen_fail}
|
|
end.
|
|
|
|
|
|
-spec halt_if_exists(file:filename()) -> ok | no_return().
|
|
%% @private
|
|
%% A helper function to guard against overwriting an existing file. Halts execution if
|
|
%% the file is found to exist.
|
|
|
|
halt_if_exists(Path) ->
|
|
case filelib:is_file(Path) of
|
|
true ->
|
|
ok = log(error, "~ts already exists! Halting.", [Path]),
|
|
halt(1);
|
|
false ->
|
|
ok
|
|
end.
|
|
|
|
|
|
-spec gen_p_key(KeyFile) -> ok
|
|
when KeyFile :: file:filename().
|
|
%% @private
|
|
%% Format an openssl shell command that will generate proper 16k RSA keys.
|
|
|
|
gen_p_key(KeyFile) ->
|
|
Command =
|
|
io_lib:format("~ts genpkey"
|
|
" -algorithm rsa"
|
|
" -out ~ts"
|
|
" -outform DER"
|
|
" -pkeyopt rsa_keygen_bits:16384",
|
|
[openssl(), KeyFile]),
|
|
Out = os:cmd(Command),
|
|
io:format(Out).
|
|
|
|
|
|
-spec der_to_pem(KeyFile, PemFile) -> ok
|
|
when KeyFile :: file:filename(),
|
|
PemFile :: file:filename().
|
|
%% @private
|
|
%% Format an openssl shell command that will convert the given keyfile to a pemfile.
|
|
%% The reason for this conversion is to sidestep some formatting weirdness that OpenSSL
|
|
%% injects into its generated DER formatted key output (namely, a few empty headers)
|
|
%% which Erlang's ASN.1 defintion files do not take into account. A conversion to PEM
|
|
%% then a conversion back to DER (via Erlang's ASN.1 module) resolves this in a reliable
|
|
%% way.
|
|
|
|
der_to_pem(KeyFile, PemFile) ->
|
|
Command =
|
|
io_lib:format("~ts rsa"
|
|
" -inform DER"
|
|
" -in ~ts"
|
|
" -outform PEM"
|
|
" -pubout"
|
|
" -out ~ts",
|
|
[openssl(), KeyFile, PemFile]),
|
|
Out = os:cmd(Command),
|
|
io:format(Out).
|
|
|
|
|
|
-spec check_key(KeyFile, PubFile) -> Result
|
|
when KeyFile :: file:filename(),
|
|
PubFile :: file:filename(),
|
|
Result :: true | false.
|
|
%% @private
|
|
%% Compare two keys for pairedness.
|
|
|
|
check_key(KeyFile, PubFile) ->
|
|
{ok, KeyBin} = file:read_file(KeyFile),
|
|
{ok, PubBin} = file:read_file(PubFile),
|
|
Key = public_key:der_decode('RSAPrivateKey', KeyBin),
|
|
Pub = public_key:der_decode('RSAPublicKey', PubBin),
|
|
TestMessage = <<"Some test data to sign.">>,
|
|
Signature = public_key:sign(TestMessage, sha512, Key),
|
|
public_key:verify(TestMessage, sha512, Signature, Pub).
|
|
|
|
|
|
-spec openssl() -> Executable | no_return()
|
|
when Executable :: file:filename().
|
|
%% @private
|
|
%% Attempt to locate the installed openssl executable for use in shell commands.
|
|
%% Halts execution with an error message if the executable cannot be found.
|
|
|
|
openssl() ->
|
|
OpenSSL =
|
|
case os:type() of
|
|
{unix, _} -> "openssl";
|
|
{win32, _} -> "openssl.exe"
|
|
end,
|
|
ok =
|
|
case os:find_executable(OpenSSL) of
|
|
false ->
|
|
ok = log(error, "OpenSSL could not be found in this system's PATH."),
|
|
ok = log(error, "Install OpenSSL and then retry."),
|
|
error_exit("Missing system dependenct: OpenSSL", ?FILE, ?LINE);
|
|
Path ->
|
|
log(info, "OpenSSL executable found at: ~ts", [Path])
|
|
end,
|
|
OpenSSL.
|
|
|
|
|
|
-spec loadkey(Type, KeyID) -> Result
|
|
when Type :: private | public,
|
|
KeyID :: key_id(),
|
|
Result :: {ok, DecodedKey :: term()}
|
|
| {error, Reason :: term()}.
|
|
%% @private
|
|
%% Hide the details behind reading and loading DER encoded RSA key files.
|
|
|
|
loadkey(Type, {Realm, KeyName}) ->
|
|
{DerType, Path} =
|
|
case Type of
|
|
private ->
|
|
P = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".key.der"]),
|
|
{'RSAPrivateKey', P};
|
|
public ->
|
|
P = filename:join([zomp_dir(), "key", Realm, KeyName ++ ".pub.der"]),
|
|
{'RSAPublicKey', P}
|
|
end,
|
|
ok = log(info, "Loading key from file ~ts", [Path]),
|
|
case file:read_file(Path) of
|
|
{ok, Bin} -> {ok, public_key:der_decode(DerType, Bin)};
|
|
Error -> Error
|
|
end.
|
|
|
|
|
|
|
|
%%% Generate PLT
|
|
|
|
-spec create_plt() -> no_return().
|
|
%% @private
|
|
%% Generate a fresh PLT file that includes most basic core applications needed to
|
|
%% make a resonable estimate of a type system, write the name of the PLT to stdout,
|
|
%% and exit.
|
|
|
|
create_plt() ->
|
|
ok = build_plt(),
|
|
halt(0).
|
|
|
|
|
|
build_plt() ->
|
|
PLT = default_plt(),
|
|
Template =
|
|
"dialyzer --build_plt"
|
|
" --output_plt ~ts"
|
|
" --apps asn1 reltool wx common_test crypto erts eunit inets"
|
|
" kernel mnesia public_key sasl ssh ssl stdlib",
|
|
Command = io_lib:format(Template, [PLT]),
|
|
Message =
|
|
"Generating PLT file and writing to: ~tp~n"
|
|
" There will be a list of \"unknown functions\" in the final output.~n"
|
|
" Don't panic. This is normal. Turtles all the way down, after all...",
|
|
ok = log(info, Message, [PLT]),
|
|
ok = log(info, "This may take a while. Patience is a virtue."),
|
|
Out = os:cmd(Command),
|
|
log(info, Out).
|
|
|
|
|
|
default_plt() ->
|
|
filename:join(zomp_dir(), "basic.plt").
|
|
|
|
|
|
|
|
%%% Dialyze
|
|
|
|
-spec dialyze() -> no_return().
|
|
%% @private
|
|
%% Preps a copy of this script for typechecking with Dialyzer.
|
|
|
|
dialyze() ->
|
|
PLT = default_plt(),
|
|
ok =
|
|
case filelib:is_regular(PLT) of
|
|
true -> log(info, "Using PLT: ~tp", [PLT]);
|
|
false -> build_plt()
|
|
end,
|
|
TmpDir = filename:join(zomp_dir(), "tmp"),
|
|
Me = escript:script_name(),
|
|
EvilTwin = filename:join(TmpDir, filename:basename(Me ++ ".erl")),
|
|
ok = log(info, "Temporarily reconstructing ~tp as ~tp", [Me, EvilTwin]),
|
|
Sed = io_lib:format("sed 's/^#!.*$//' ~s > ~s", [Me, EvilTwin]),
|
|
"" = os:cmd(Sed),
|
|
ok = case dialyzer:run([{init_plt, PLT}, {from, src_code}, {files, [EvilTwin]}]) of
|
|
[] ->
|
|
io:format("Dialyzer found no errors and returned no warnings! Yay!~n");
|
|
Warnings ->
|
|
Mine = [dialyzer:format_warning({Tag, {Me, Line}, Msg})
|
|
|| {Tag, {_, Line}, Msg} <- Warnings],
|
|
lists:foreach(fun io:format/1, Mine)
|
|
end,
|
|
ok = file:delete(EvilTwin),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% Create Realm & Sysop
|
|
|
|
-spec create_user(realm(), username()) -> no_return().
|
|
%% @private
|
|
%% Validate the realm and username provided, prompt the user to either select a keypair
|
|
%% to use or generate a new one, and bundle a .zuser file for conveyance of the user
|
|
%% data and his relevant keys (for import into an existing zomp server via `add'
|
|
%% command like "add packager", "add maintainer" and "add sysop".
|
|
|
|
create_user(Realm, Username) ->
|
|
Message = "Would be generating a user file for {~160tp, ~160to}.",
|
|
ok = log(info, Message, [Realm, Username]),
|
|
halt(0).
|
|
|
|
|
|
-spec create_realm() -> no_return().
|
|
%% @private
|
|
%% Prompt the user to input the information necessary to create a new zomp realm,
|
|
%% package the data appropriately for the server and deliver the final keys and
|
|
%% realm file to the user.
|
|
|
|
create_realm() ->
|
|
ConfFile = filename:join(zomp_dir(), "zomp.conf"),
|
|
case file:consult(ConfFile) of
|
|
{ok, ZompConf} -> create_realm(ZompConf);
|
|
{error, enoent} -> create_realm([])
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}].
|
|
|
|
create_realm(ZompConf) ->
|
|
Instructions =
|
|
"~n"
|
|
" Enter a name for your new realm.~n"
|
|
" Names can contain only lower-case letters, numbers and the underscore.~n"
|
|
" Names must begin with a lower-case letter.~n",
|
|
ok = io:format(Instructions),
|
|
Realm = get_input(),
|
|
case valid_lower0_9(Realm) of
|
|
true ->
|
|
RealmFile = filename:join(zomp_dir(), Realm ++ ".realm"),
|
|
case filelib:is_regular(RealmFile) of
|
|
false ->
|
|
create_realm(ZompConf, Realm);
|
|
true ->
|
|
ok = io:format("That realm already exists. Be more original.~n"),
|
|
create_realm(ZompConf)
|
|
end;
|
|
false ->
|
|
ok = io:format("Bad realm name \"~ts\". Try again.~n", [Realm]),
|
|
create_realm(ZompConf)
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm().
|
|
|
|
create_realm(ZompConf, Realm) ->
|
|
ExAddress =
|
|
case lists:keyfind(external_address, 1, ZompConf) of
|
|
false -> prompt_external_address();
|
|
{external_address, none} -> prompt_external_address();
|
|
{external_address, Current} -> prompt_external_address(Current)
|
|
end,
|
|
create_realm(ZompConf, Realm, ExAddress).
|
|
|
|
|
|
-spec prompt_external_address() -> Result
|
|
when Result :: inet:hostname() | inet:ip_address().
|
|
|
|
prompt_external_address() ->
|
|
Message = external_address_prompt(),
|
|
ok = io:format(Message),
|
|
case get_input() of
|
|
"" ->
|
|
ok = io:format("You need to enter an address.~n"),
|
|
prompt_external_address();
|
|
String ->
|
|
parse_address(String)
|
|
end.
|
|
|
|
|
|
-spec prompt_external_address(Current) -> Result
|
|
when Current :: inet:hostname() | inet:ip_address(),
|
|
Result :: inet:hostname() | inet:ip_address().
|
|
|
|
prompt_external_address(Current) ->
|
|
XAString =
|
|
case inet:ntoa(Current) of
|
|
{error, einval} -> Current;
|
|
XAS -> XAS
|
|
end,
|
|
Message =
|
|
external_address_prompt() ++
|
|
" [The current public address is: ~ts. Press <ENTER> to keep this address.]~n",
|
|
ok = io:format(Message, [XAString]),
|
|
case get_input() of
|
|
"" -> Current;
|
|
String -> parse_address(String)
|
|
end.
|
|
|
|
|
|
-spec external_address_prompt() -> string().
|
|
|
|
external_address_prompt() ->
|
|
"~n"
|
|
" Enter a static, valid hostname or IPv4 or IPv6 address at which this host "
|
|
"can be reached from the public internet (or internal network if it will never "
|
|
"need to be reached from the internet).~n"
|
|
" DO NOT INCLUDE A PORT NUMBER IN THIS STEP~n".
|
|
|
|
|
|
-spec parse_address(string()) -> inet:hostname() | inet:ip_address().
|
|
|
|
parse_address(String) ->
|
|
case inet:parse_address(String) of
|
|
{ok, Address} -> Address;
|
|
{error, einval} -> String
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress) ->
|
|
Current =
|
|
case lists:keyfind(external_port, 1, ZompConf) of
|
|
false -> 11311;
|
|
{external_port, none} -> 11311;
|
|
{external_port, P} -> P
|
|
end,
|
|
Message =
|
|
"~n"
|
|
" Enter the public (external) port number at which this service should be "
|
|
"available. (This might be different from the local port number if you are "
|
|
"forwarding ports or have a complex network layout.)~n",
|
|
ok = io:format(Message),
|
|
ExPort = prompt_port_number(Current),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort).
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress, ExPort) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address(),
|
|
ExPort :: inet:port_number().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort) ->
|
|
Current =
|
|
case lists:keyfind(internal_port, 1, ZompConf) of
|
|
false -> 11311;
|
|
{internal_port, none} -> 11311;
|
|
{internal_port, P} -> P
|
|
end,
|
|
Message =
|
|
"~n"
|
|
" Enter the local (internal/LAN) port number at which this service should be "
|
|
"available. (This might be different from the public port visible from the "
|
|
"internet if you are port forwarding or have a complex network layout.)~n",
|
|
ok = io:format(Message),
|
|
InPort = prompt_port_number(Current),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort).
|
|
|
|
|
|
-spec prompt_port_number(Current) -> Result
|
|
when Current :: inet:port_number(),
|
|
Result :: inet:port_number().
|
|
|
|
prompt_port_number(Current) ->
|
|
Instructions =
|
|
" A valid port is any number from 1 to 65535."
|
|
" [Press enter to accept the current setting: ~tw]~n",
|
|
ok = io:format(Instructions, [Current]),
|
|
case get_input() of
|
|
"" ->
|
|
Current;
|
|
S ->
|
|
try
|
|
case list_to_integer(S) of
|
|
Port when 16#ffff >= Port, Port > 0 ->
|
|
Port;
|
|
Illegal ->
|
|
Whoops = "Whoops! ~tw is out of bounds (1~65535). Try again.~n",
|
|
ok = io:format(Whoops, [Illegal]),
|
|
prompt_port_number(Current)
|
|
end
|
|
catch error:badarg ->
|
|
ok = io:format("~tp is not a port number. Try again...", [S]),
|
|
prompt_port_number(Current)
|
|
end
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address(),
|
|
ExPort :: inet:port_number(),
|
|
InPort :: inet:port_number().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort) ->
|
|
Instructions =
|
|
"~n"
|
|
" Enter a username for the realm sysop.~n"
|
|
" Names can contain only lower-case letters, numbers and the underscore.~n"
|
|
" Names must begin with a lower-case letter.~n",
|
|
ok = io:format(Instructions),
|
|
UserName = get_input(),
|
|
case valid_lower0_9(UserName) of
|
|
true ->
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName);
|
|
false ->
|
|
ok = io:format("Bad username ~tp. Try again.~n", [UserName]),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort)
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName) -> no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address(),
|
|
ExPort :: inet:port_number(),
|
|
InPort :: inet:port_number(),
|
|
UserName :: string().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName) ->
|
|
Instructions =
|
|
"~n"
|
|
" Enter an email address for the realm sysop.~n"
|
|
" Valid email address rules apply though the checking done here is quite "
|
|
"minimal. Check the address you enter carefully. The only people who will "
|
|
"suffer from an invalid address are your users.~n",
|
|
ok = io:format(Instructions),
|
|
Email = get_input(),
|
|
[User, Host] = string:lexemes(Email, "@"),
|
|
case {valid_lower0_9(User), valid_label(Host)} of
|
|
{true, true} ->
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email);
|
|
{false, true} ->
|
|
Message = "The user part of the email address seems invalid. Try again.~n",
|
|
ok = io:format(Message),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName);
|
|
{true, false} ->
|
|
Message = "The host part of the email address seems invalid. Try again.~n",
|
|
ok = io:format(Message),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName);
|
|
{false, false} ->
|
|
Message = "This email address seems like its totally bonkers. Try again.~n",
|
|
ok = io:format(Message),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName)
|
|
end.
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email) ->
|
|
no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address(),
|
|
ExPort :: inet:port_number(),
|
|
InPort :: inet:port_number(),
|
|
UserName :: string(),
|
|
Email :: string().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email) ->
|
|
Instructions =
|
|
"~n"
|
|
" Enter the real name (or whatever name people recognize) for the sysop.~n"
|
|
" There are no rules for this one. Any valid UTF-8 printables are legal.~n",
|
|
ok = io:format(Instructions),
|
|
RealName = get_input(),
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName).
|
|
|
|
|
|
-spec create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) ->
|
|
no_return()
|
|
when ZompConf :: [{Key :: atom(), Value :: term()}],
|
|
Realm :: realm(),
|
|
ExAddress :: inet:hostname() | inet:ip_address(),
|
|
ExPort :: inet:port_number(),
|
|
InPort :: inet:port_number(),
|
|
UserName :: string(),
|
|
Email :: string(),
|
|
RealName :: string().
|
|
|
|
create_realm(ZompConf, Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) ->
|
|
ok = io:format("~nGenerating keys. This might take a while, so settle in...~n"),
|
|
{ok, RealmKey, RealmPub} = generate_rsa({Realm, Realm ++ ".1.realm"}),
|
|
{ok, PackageKey, PackagePub} = generate_rsa({Realm, Realm ++ ".1.package"}),
|
|
{ok, SysopKey, SysopPub} = generate_rsa({Realm, UserName ++ ".1"}),
|
|
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [RealmKey, RealmPub]),
|
|
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [PackageKey, PackagePub]),
|
|
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [SysopKey, SysopPub]),
|
|
|
|
Timestamp = calendar:now_to_universal_time(erlang:timestamp()),
|
|
|
|
{ok, RealmPubData} = file:read_file(RealmPub),
|
|
RealmPubRecord =
|
|
{{Realm, filename:basename(RealmPub, ".pub.der")},
|
|
realm,
|
|
{realm, Realm},
|
|
crypto:hash(sha512, RealmPubData),
|
|
Timestamp},
|
|
{ok, PackagePubData} = file:read_file(PackagePub),
|
|
PackagePubRecord =
|
|
{{Realm, filename:basename(PackagePub, ".pub.der")},
|
|
package,
|
|
{realm, Realm},
|
|
crypto:hash(sha512, PackagePubData),
|
|
Timestamp},
|
|
UserRecord =
|
|
{{Realm, UserName},
|
|
[filename:basename(SysopPub, ".pub.der")],
|
|
Email,
|
|
RealName},
|
|
RealmSettings =
|
|
[{realm, Realm},
|
|
{revision, 0},
|
|
{prime, {ExAddress, ExPort}},
|
|
{private, []},
|
|
{mirrors, []},
|
|
{sysops, [UserRecord]},
|
|
{realm_keys, [RealmPubRecord]},
|
|
{package_keys, [PackagePubRecord]}],
|
|
ZompSettings =
|
|
[{managed, [Realm]},
|
|
{external_address, ExAddress},
|
|
{external_port, ExPort},
|
|
{internal_port, InPort}],
|
|
|
|
RealmFN = Realm ++ ".realm",
|
|
RealmConf = filename:join(zomp_dir(), RealmFN),
|
|
ok = write_terms(RealmConf, RealmSettings),
|
|
{ok, CWD} = file:get_cwd(),
|
|
{ok, TempDir} = mktemp_dir("zomp"),
|
|
ok = file:set_cwd(TempDir),
|
|
KeyDir = filename:join("key", Realm),
|
|
ok = filelib:ensure_dir(KeyDir),
|
|
ok = file:make_dir(KeyDir),
|
|
KeyCopy =
|
|
fun(K) ->
|
|
{ok, _} = file:copy(K, filename:join(KeyDir, filename:basename(K))),
|
|
ok
|
|
end,
|
|
TarOpts = [compressed, {cwd, TempDir}],
|
|
|
|
ok = write_terms(RealmFN, RealmSettings),
|
|
ok = KeyCopy(PackagePub),
|
|
ok = KeyCopy(RealmPub),
|
|
PublicZRF = filename:join(CWD, Realm ++ ".zrf"),
|
|
Files = filelib:wildcard("**"),
|
|
ok = erl_tar:create(PublicZRF, [RealmFN, "key"], TarOpts),
|
|
|
|
ok = KeyCopy(SysopPub),
|
|
ok = write_terms("zomp.conf", ZompSettings),
|
|
PrimeZRF = filename:join(CWD, Realm ++ ".zpf"),
|
|
ok = erl_tar:create(PrimeZRF, [RealmFN, "zomp.conf", "key"], TarOpts),
|
|
|
|
ok = file:set_cwd(zomp_dir()),
|
|
KeyBundle = filename:join(CWD, Realm ++ ".zkf"),
|
|
ok = erl_tar:create(KeyBundle, [KeyDir], [compressed]),
|
|
|
|
ok = file:set_cwd(CWD),
|
|
ok = rm_rf(TempDir),
|
|
|
|
Message =
|
|
"============================================================================~n"
|
|
"DONE!~n"
|
|
"~n"
|
|
"The realm ~ts has been created and is accessible from the current system.~n"
|
|
"Three configuration bundles have been created in the current directory:~n"
|
|
"~n"
|
|
" 1. ~ts ~n"
|
|
"This is the PRIVATE realm file you will need to install on the realm's prime~n"
|
|
"node. It includes the your (the sysop's) public key.~n"
|
|
"~n"
|
|
" 2. ~ts ~n"
|
|
"This file is the PUBLIC realm file other zomp nodes and zx users will need to~n"
|
|
"access the realm. It does not include your (the sysop's) public key.~n"
|
|
"~n"
|
|
" 3. ~ts ~n"
|
|
"This is the bundle of ALL KEYS that are defined in this realm at the moment.~n"
|
|
"~n"
|
|
"Now you need to make copies of these three files and back them up.~n"
|
|
"~n"
|
|
"On the PRIME NODE you need to run `zx add realm ~ts` and follow the prompts~n"
|
|
"to cause it to begin serving that realm as prime. (Node restart required.)~n"
|
|
"~n"
|
|
"On all zx CLIENTS that want to access your new realm and on all subordinate~n"
|
|
"MIRROR NODES the command `zx add realm ~ts` will need to be run.~n"
|
|
"The method of public realm file distribution (~ts) is up to you.~n"
|
|
"~n"
|
|
"~n"
|
|
"Public & Private key installation (if you need to recover them or perform~n"
|
|
"sysop functions from another computer) is `zx add keybundle ~ts`.~n"
|
|
"============================================================================~n",
|
|
Substitutions =
|
|
[Realm,
|
|
PrimeZRF, PublicZRF, KeyBundle,
|
|
PrimeZRF,
|
|
PublicZRF, PublicZRF,
|
|
KeyBundle],
|
|
ok = io:format(Message, Substitutions),
|
|
halt(0).
|
|
|
|
|
|
-spec create_realmfile(realm()) -> no_return().
|
|
|
|
create_realmfile(Realm) ->
|
|
RealmConf = load_realm_conf(Realm),
|
|
ok = log(info, "Realm found, creating realm file..."),
|
|
{revision, Revision} = lists:keyfind(revision, 1, RealmConf),
|
|
{realm_keys, RealmKeys} = lists:keyfind(realm_keys, 1, RealmConf),
|
|
{package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf),
|
|
RealmKeyIDs = [element(1, K) || K <- RealmKeys],
|
|
PackageKeyIDs = [element(1, K) || K <- PackageKeys],
|
|
create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs).
|
|
|
|
|
|
-spec create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) -> ok
|
|
when Realm :: realm(),
|
|
Revision :: non_neg_integer(),
|
|
RealmKeyIDs :: [key_id()],
|
|
PackageKeyIDs :: [key_id()].
|
|
|
|
create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) ->
|
|
{ok, CWD} = file:get_cwd(),
|
|
ok = file:set_cwd(zomp_dir()),
|
|
KeyPath = fun({R, K}) -> filename:join(["key", R, K ++ ".pub.der"]) end,
|
|
RealmKeyPaths = lists:map(KeyPath, RealmKeyIDs),
|
|
PackageKeyPaths = lists:map(KeyPath, PackageKeyIDs),
|
|
Targets = [realm_conf(Realm) | RealmKeyPaths ++ PackageKeyPaths],
|
|
OutFile = filename:join(CWD, Realm ++ "." ++ integer_to_list(Revision) ++ ".zrf"),
|
|
ok = erl_tar:create(OutFile, Targets, [compressed]),
|
|
ok = log(info, "Realm conf file written to ~ts", [OutFile]),
|
|
halt(0).
|
|
|
|
|
|
-spec create_sysop() -> no_return().
|
|
|
|
create_sysop() ->
|
|
ok = log(info, "Fo' realz, yo! We be sysoppin up in hurr!"),
|
|
halt(0).
|
|
|
|
|
|
|
|
%%% Network operations and package utilities
|
|
|
|
|
|
-spec install(package_id()) -> ok.
|
|
%% @private
|
|
%% Install a package from the cache into the local system.
|
|
|
|
install(PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
ok = log(info, "Installing ~ts", [PackageString]),
|
|
ZrpFile = filename:join("zrp", namify_zrp(PackageID)),
|
|
Files = extract_zrp(ZrpFile),
|
|
TgzFile = namify_tgz(PackageID),
|
|
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
|
|
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
|
|
Meta = binary_to_term(MetaBin),
|
|
{KeyID, Signature} = maps:get(sig, Meta),
|
|
{ok, PubKey} = loadkey(public, KeyID),
|
|
ok = ensure_package_dirs(PackageID),
|
|
PackageDir = filename:join("lib", PackageString),
|
|
ok = force_dir(PackageDir),
|
|
ok = verify(TgzData, Signature, PubKey),
|
|
ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageDir}]),
|
|
log(info, "~ts installed", [PackageString]).
|
|
|
|
|
|
-spec extract_zrp(FileName) -> Files | no_return()
|
|
when FileName :: file:filename(),
|
|
Files :: [{file:filename(), binary()}].
|
|
%% @private
|
|
%% Extract a zrp archive, if possible. If not possible, halt execution with as accurate
|
|
%% an error message as can be managed.
|
|
|
|
extract_zrp(FileName) ->
|
|
case erl_tar:extract(FileName, [memory]) of
|
|
{ok, Files} ->
|
|
Files;
|
|
{error, {FileName, enoent}} ->
|
|
Message = "Can't find file ~ts.",
|
|
error_exit(Message, [FileName], ?FILE, ?LINE);
|
|
{error, invalid_tar_checksum} ->
|
|
Message = "~ts is not a valid zrp archive.",
|
|
error_exit(Message, [FileName], ?FILE, ?LINE);
|
|
{error, Reason} ->
|
|
Message = "Extracting package file failed with: ~160tp.",
|
|
error_exit(Message, [Reason], ?FILE, ?LINE)
|
|
end.
|
|
|
|
|
|
-spec verify(Data, Signature, PubKey) -> ok | no_return()
|
|
when Data :: binary(),
|
|
Signature :: binary(),
|
|
PubKey :: public_key:rsa_public_key().
|
|
%% @private
|
|
%% Verify the RSA Signature of some Data against the given PubKey or halt execution.
|
|
%% This function always assumes sha512 is the algorithm being used.
|
|
|
|
verify(Data, Signature, PubKey) ->
|
|
case public_key:verify(Data, sha512, Signature, PubKey) of
|
|
true -> ok;
|
|
false -> error_exit("Bad package signature!", ?FILE, ?LINE)
|
|
end.
|
|
|
|
|
|
-spec fetch(Socket, PackageID) -> Result
|
|
when Socket :: gen_tcp:socket(),
|
|
PackageID :: package_id(),
|
|
Result :: ok.
|
|
%% @private
|
|
%% Download a package to the local cache.
|
|
|
|
fetch(Socket, PackageID) ->
|
|
{ok, LatestID} = request_zrp(Socket, PackageID),
|
|
ok = receive_zrp(Socket, LatestID),
|
|
log(info, "Fetched ~ts", [package_string(LatestID)]).
|
|
|
|
|
|
-spec request_zrp(Socket, PackageID) -> Result
|
|
when Socket :: gen_tcp:socket(),
|
|
PackageID :: package_id(),
|
|
Result :: {ok, Latest :: package_id()}
|
|
| {error, Reason :: timeout | term()}.
|
|
|
|
request_zrp(Socket, PackageID) ->
|
|
ok = send(Socket, {fetch, PackageID}),
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
case binary_to_term(Bin) of
|
|
{sending, LatestID} ->
|
|
{ok, LatestID};
|
|
Error = {error, Reason} ->
|
|
PackageString = package_string(PackageID),
|
|
Message = "Error receiving package ~ts: ~tp",
|
|
ok = log(info, Message, [PackageString, Reason]),
|
|
Error
|
|
end;
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 60000 ->
|
|
{error, timeout}
|
|
end.
|
|
|
|
|
|
-spec receive_zrp(Socket, PackageID) -> Result
|
|
when Socket :: gen_tcp:socket(),
|
|
PackageID :: package_id(),
|
|
Result :: ok | {error, timeout}.
|
|
|
|
receive_zrp(Socket, PackageID) ->
|
|
receive
|
|
{tcp, Socket, Bin} ->
|
|
ZrpPath = filename:join("zrp", namify_zrp(PackageID)),
|
|
ok = file:write_file(ZrpPath, Bin),
|
|
ok = send(Socket, ok),
|
|
log(info, "Wrote ~ts", [ZrpPath]);
|
|
{tcp_closed, Socket} ->
|
|
halt_on_unexpected_close()
|
|
after 60000 ->
|
|
ok = log(error, "Timeout in socket receive for ~tp", [PackageID]),
|
|
{error, timeout}
|
|
end.
|
|
|
|
|
|
-spec mktemp_dir(Prefix) -> Result
|
|
when Prefix :: string(),
|
|
Result :: {ok, TempDir :: file:filename()}
|
|
| {error, Reason :: file:posix()}.
|
|
|
|
mktemp_dir(Prefix) ->
|
|
Rand = integer_to_list(binary:decode_unsigned(crypto:strong_rand_bytes(8)), 36),
|
|
TempPath = filename:basedir(user_cache, Prefix),
|
|
TempDir = filename:join(TempPath, Rand),
|
|
Result1 = filelib:ensure_dir(TempDir),
|
|
Result2 = file:make_dir(TempDir),
|
|
case {Result1, Result2} of
|
|
{ok, ok} -> {ok, TempDir};
|
|
{ok, Error} -> Error;
|
|
{Error, _} -> Error
|
|
end.
|
|
|
|
|
|
%%% Utility functions
|
|
|
|
-spec read_meta() -> package_meta() | no_return().
|
|
%% @private
|
|
%% @equiv read_meta(".")
|
|
|
|
read_meta() ->
|
|
read_meta(".").
|
|
|
|
|
|
-spec read_meta(Dir) -> package_meta() | no_return()
|
|
when Dir :: file:filename().
|
|
%% @private
|
|
%% Read the `zomp.meta' file from the indicated directory, if possible. If not possible
|
|
%% then halt execution with an appropriate error message.
|
|
|
|
read_meta(Dir) ->
|
|
Path = filename:join(Dir, "zomp.meta"),
|
|
case file:consult(Path) of
|
|
{ok, Meta} ->
|
|
maps:from_list(Meta);
|
|
Error ->
|
|
ok = log(error, "Failed to open \"zomp.meta\" with ~tp", [Error]),
|
|
ok = log(error, "Wrong directory?"),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
-spec write_meta(package_meta()) -> ok.
|
|
%% @private
|
|
%% @equiv write_meta(".")
|
|
|
|
write_meta(Meta) ->
|
|
write_meta(".", Meta).
|
|
|
|
|
|
-spec write_meta(Dir, Meta) -> ok
|
|
when Dir :: file:filename(),
|
|
Meta :: package_meta().
|
|
%% @private
|
|
%% Write the contents of the provided meta structure (a map these days) as a list of
|
|
%% Erlang K/V terms.
|
|
|
|
write_meta(Dir, Meta) ->
|
|
Path = filename:join(Dir, "zomp.meta"),
|
|
ok = write_terms(Path, maps:to_list(Meta)).
|
|
|
|
|
|
-spec write_terms(Filename, Terms) -> ok
|
|
when Filename :: file:filename(),
|
|
Terms :: [term()].
|
|
%% @private
|
|
%% Provides functionality roughly inverse to file:consult/1.
|
|
|
|
write_terms(Filename, List) ->
|
|
Format = fun(Term) -> io_lib:format("~tp.~n", [Term]) end,
|
|
Text = lists:map(Format, List),
|
|
file:write_file(Filename, Text).
|
|
|
|
|
|
-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(package_home(PackageID)),
|
|
ok = build(),
|
|
file:set_cwd(CWD).
|
|
|
|
|
|
-spec build() -> ok.
|
|
%% @private
|
|
%% Run any local `zxmake' script needed by the project for non-Erlang code (if present),
|
|
%% then add the local `ebin/' directory to the runtime search path, and finally build
|
|
%% the Erlang part of the project with make:all/0 according to the local `Emakefile'.
|
|
|
|
build() ->
|
|
ZxMake = "zxmake",
|
|
ok =
|
|
case filelib:is_regular(ZxMake) of
|
|
true ->
|
|
Out = os:cmd(ZxMake),
|
|
log(info, Out);
|
|
false ->
|
|
ok
|
|
end,
|
|
true = code:add_patha(filename:absname("ebin")),
|
|
up_to_date = make:all(),
|
|
ok.
|
|
|
|
|
|
-spec scrub(Deps) -> Scrubbed
|
|
when Deps :: [package_id()],
|
|
Scrubbed :: [package_id()].
|
|
%% @private
|
|
%% Take a list of dependencies and return a list of dependencies that are not yet
|
|
%% installed on the system.
|
|
|
|
scrub([]) ->
|
|
[];
|
|
scrub(Deps) ->
|
|
lists:filter(fun(PackageID) -> not installed(PackageID) end, Deps).
|
|
|
|
|
|
-spec installed(package_id()) -> boolean().
|
|
%% @private
|
|
%% True to its name, returns `true' if the package is installed (its directory found),
|
|
%% `false' otherwise.
|
|
|
|
installed(PackageID) ->
|
|
PackageString = package_string(PackageID),
|
|
PackageDir = filename:join("lib", PackageString),
|
|
filelib:is_dir(PackageDir).
|
|
|
|
|
|
-spec rm_rf(file:filename()) -> ok | {error, file:posix()}.
|
|
%% @private
|
|
%% Recursively remove files and directories, equivalent to `rm -rf' on unix.
|
|
|
|
rm_rf(Path) ->
|
|
case filelib:is_dir(Path) of
|
|
true ->
|
|
Pattern = filename:join(Path, "**"),
|
|
Contents = lists:reverse(lists:sort(filelib:wildcard(Pattern))),
|
|
ok = lists:foreach(fun rm/1, Contents),
|
|
file:del_dir(Path);
|
|
false ->
|
|
file:delete(Path)
|
|
end.
|
|
|
|
|
|
-spec rm(file:filename()) -> ok | {error, file:posix()}.
|
|
%% @private
|
|
%% An omnibus delete helper.
|
|
|
|
rm(Path) ->
|
|
case filelib:is_dir(Path) of
|
|
true -> file:del_dir(Path);
|
|
false -> file:delete(Path)
|
|
end.
|
|
|
|
|
|
|
|
%%% Input argument mangling
|
|
|
|
|
|
-spec valid_lower0_9(string()) -> boolean().
|
|
%% @private
|
|
%% Check whether a provided string is a valid lower0_9.
|
|
|
|
valid_lower0_9([Char | Rest])
|
|
when $a =< Char, Char =< $z ->
|
|
valid_lower0_9(Rest, Char);
|
|
valid_lower0_9(_) ->
|
|
false.
|
|
|
|
|
|
-spec valid_lower0_9(String, Last) -> boolean()
|
|
when String :: string(),
|
|
Last :: char().
|
|
|
|
valid_lower0_9([$_ | _], $_) ->
|
|
false;
|
|
valid_lower0_9([Char | Rest], _)
|
|
when $a =< Char, Char =< $z;
|
|
$0 =< Char, Char =< $9;
|
|
Char == $_ ->
|
|
valid_lower0_9(Rest, Char);
|
|
valid_lower0_9([], _) ->
|
|
true;
|
|
valid_lower0_9(_, _) ->
|
|
false.
|
|
|
|
|
|
-spec valid_label(string()) -> boolean().
|
|
%% @private
|
|
%% Check whether a provided string is a valid label.
|
|
|
|
valid_label([Char | Rest])
|
|
when $a =< Char, Char =< $z ->
|
|
valid_label(Rest, Char);
|
|
valid_label(_) ->
|
|
false.
|
|
|
|
|
|
-spec valid_label(String, Last) -> boolean()
|
|
when String :: string(),
|
|
Last :: char().
|
|
|
|
valid_label([$. | _], $.) ->
|
|
false;
|
|
valid_label([$_ | _], $_) ->
|
|
false;
|
|
valid_label([$- | _], $-) ->
|
|
false;
|
|
valid_label([Char | Rest], _)
|
|
when $a =< Char, Char =< $z;
|
|
$0 =< Char, Char =< $9;
|
|
Char == $_; Char == $-;
|
|
Char == $. ->
|
|
valid_label(Rest, Char);
|
|
valid_label([], _) ->
|
|
true;
|
|
valid_label(_, _) ->
|
|
false.
|
|
|
|
|
|
-spec string_to_version(string()) -> version().
|
|
%% @private
|
|
%% @equiv string_to_version(string(), "", {z, z, z})
|
|
|
|
string_to_version(String) ->
|
|
string_to_version(String, "", {z, z, z}).
|
|
|
|
|
|
-spec string_to_version(String, Acc, Version) -> Result
|
|
when String :: string(),
|
|
Acc :: list(),
|
|
Version :: version(),
|
|
Result :: version().
|
|
%% @private
|
|
%% Accepts a full or partial version string of the form `X.Y.Z', `X.Y' or `X' and
|
|
%% returns a zomp-type version tuple or crashes on bad data.
|
|
|
|
string_to_version([Char | Rest], Acc, Version) when $0 =< Char andalso Char =< $9 ->
|
|
string_to_version(Rest, [Char | Acc], Version);
|
|
string_to_version([$. | Rest], Acc, {z, z, z}) ->
|
|
X = list_to_integer(lists:reverse(Acc)),
|
|
string_to_version(Rest, "", {X, z, z});
|
|
string_to_version([$. | Rest], Acc, {X, z, z}) ->
|
|
Y = list_to_integer(lists:reverse(Acc)),
|
|
string_to_version(Rest, "", {X, Y, z});
|
|
string_to_version("", "", Version) ->
|
|
Version;
|
|
string_to_version([], Acc, {z, z, z}) ->
|
|
X = list_to_integer(lists:reverse(Acc)),
|
|
{X, z, z};
|
|
string_to_version([], Acc, {X, z, z}) ->
|
|
Y = list_to_integer(lists:reverse(Acc)),
|
|
{X, Y, z};
|
|
string_to_version([], Acc, {X, Y, z}) ->
|
|
Z = list_to_integer(lists:reverse(Acc)),
|
|
{X, Y, Z}.
|
|
|
|
|
|
-spec version_to_string(version()) -> string().
|
|
%% @private
|
|
%% Inverse of string_to_version/3.
|
|
|
|
version_to_string({z, z, z}) ->
|
|
"";
|
|
version_to_string({X, z, z}) ->
|
|
integer_to_list(X);
|
|
version_to_string({X, Y, z}) ->
|
|
lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y]]));
|
|
version_to_string({X, Y, Z}) ->
|
|
lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y, Z]])).
|
|
|
|
|
|
-spec package_id(string()) -> package_id().
|
|
%% @private
|
|
%% Converts a proper package_string to a package_id().
|
|
%% This function takes into account missing version elements.
|
|
%% Examples:
|
|
%% `{"foo", "bar", {1, 2, 3}} = package_id("foo-bar-1.2.3")'
|
|
%% `{"foo", "bar", {1, 2, z}} = package_id("foo-bar-1.2")'
|
|
%% `{"foo", "bar", {1, z, z}} = package_id("foo-bar-1")'
|
|
%% `{"foo", "bar", {z, z, z}} = package_id("foo-bar")'
|
|
|
|
package_id(String) ->
|
|
case string:lexemes(String, [$-]) of
|
|
[Realm, Name, VersionString] ->
|
|
true = valid_lower0_9(Realm),
|
|
true = valid_lower0_9(Name),
|
|
Version = string_to_version(VersionString),
|
|
{Realm, Name, Version};
|
|
[A, B] ->
|
|
true = valid_lower0_9(A),
|
|
case valid_lower0_9(B) of
|
|
true -> {A, B, {z, z, z}};
|
|
false -> {"otpr", A, string_to_version(B)}
|
|
end;
|
|
[Name] ->
|
|
true = valid_lower0_9(Name),
|
|
{"otpr", Name, {z, z, z}}
|
|
end.
|
|
|
|
|
|
-spec package_string(package_id()) -> string().
|
|
%% @private
|
|
%% Map an PackageID to a correct string representation.
|
|
%% This function takes into account missing version elements.
|
|
%% Examples:
|
|
%% `"foo-bar-1.2.3" = package_string({"foo", "bar", {1, 2, 3}})'
|
|
%% `"foo-bar-1.2" = package_string({"foo", "bar", {1, 2, z}})'
|
|
%% `"foo-bar-1" = package_string({"foo", "bar", {1, z, z}})'
|
|
%% `"foo-bar" = package_string({"foo", "bar", {z, z, z}})'
|
|
|
|
package_string({Realm, Name, {z, z, z}}) ->
|
|
lists:flatten(lists:join($-, [Realm, Name]));
|
|
package_string({Realm, Name, Version}) ->
|
|
VersionString = version_to_string(Version),
|
|
lists:flatten(lists:join($-, [Realm, Name, VersionString])).
|
|
|
|
|
|
-spec namify_zrp(PackageID) -> ZrpFileName
|
|
when PackageID :: package_id(),
|
|
ZrpFileName :: file:filename().
|
|
%% @private
|
|
%% Map an PackageID to its correct .zrp package file name.
|
|
|
|
namify_zrp(PackageID) -> namify(PackageID, "zrp").
|
|
|
|
|
|
-spec namify_tgz(PackageID) -> TgzFileName
|
|
when PackageID :: package_id(),
|
|
TgzFileName :: file:filename().
|
|
%% @private
|
|
%% Map an PackageID to its correct gzipped tarball source bundle filename.
|
|
|
|
namify_tgz(PackageID) -> namify(PackageID, "tgz").
|
|
|
|
|
|
-spec namify(PackageID, Suffix) -> FileName
|
|
when PackageID :: package_id(),
|
|
Suffix :: string(),
|
|
FileName :: file:filename().
|
|
%% @private
|
|
%% Converts an PackageID to a canonical string, then appends the provided
|
|
%% filename Suffix.
|
|
|
|
namify(PackageID, Suffix) ->
|
|
PackageString = package_string(PackageID),
|
|
PackageString ++ "." ++ Suffix.
|
|
|
|
|
|
|
|
%%% User menu interface (terminal)
|
|
|
|
|
|
-spec get_input() -> string().
|
|
%% @private
|
|
%% Provide a standard input prompt and newline sanitized return value.
|
|
|
|
get_input() ->
|
|
string:trim(io:get_line("(^C to quit): ")).
|
|
|
|
|
|
-spec select(Options) -> Selected
|
|
when Options :: [option()],
|
|
Selected :: term().
|
|
%% @private
|
|
%% Take a list of Options to present the user, then return the indicated option to the
|
|
%% caller once the user selects something.
|
|
|
|
select(Options) ->
|
|
Max = show(Options),
|
|
case pick(string:to_integer(io:get_line("(or ^C to quit)~n ? ")), Max) of
|
|
error ->
|
|
ok = hurr(),
|
|
select(Options);
|
|
I ->
|
|
{_, Value} = lists:nth(I, Options),
|
|
Value
|
|
end.
|
|
|
|
|
|
-spec select_string(Strings) -> Selected
|
|
when Strings :: [string()],
|
|
Selected :: string().
|
|
%% @private
|
|
%% @equiv select([{S, S} || S <- Strings])
|
|
|
|
select_string(Strings) ->
|
|
select([{S, S} || S <- Strings]).
|
|
|
|
|
|
-spec show(Options) -> Index
|
|
when Options :: [option()],
|
|
Index :: pos_integer().
|
|
%% @private
|
|
%% @equiv show(Options, 0).
|
|
|
|
show(Options) ->
|
|
show(Options, 0).
|
|
|
|
|
|
-spec show(Options, Index) -> Count
|
|
when Options :: [option()],
|
|
Index :: non_neg_integer(),
|
|
Count :: pos_integer().
|
|
%% @private
|
|
%% Display the list of options needed to the user, and return the option total count.
|
|
|
|
show([], I) ->
|
|
I;
|
|
show([{Label, _} | Rest], I) ->
|
|
Z = I + 1,
|
|
ok = io:format(" ~2w - ~ts~n", [Z, Label]),
|
|
show(Rest, Z).
|
|
|
|
|
|
-spec pick({Selection, term()}, Max) -> Result
|
|
when Selection :: error | integer(),
|
|
Max :: pos_integer(),
|
|
Result :: pos_integer() | error.
|
|
%% @private
|
|
%% Interpret a user's selection returning either a valid selection index or `error'.
|
|
|
|
pick({error, _}, _) -> error;
|
|
pick({I, _}, Max) when 0 < I, I =< Max -> I;
|
|
pick(_, _) -> error.
|
|
|
|
|
|
-spec hurr() -> ok.
|
|
%% @private
|
|
%% Present an appropriate response when the user derps on selection.
|
|
|
|
hurr() -> io:format("That isn't an option.~n").
|
|
|
|
|
|
|
|
%%% Directory & File Management
|
|
|
|
-spec ensure_zomp_home() -> ok.
|
|
%% @private
|
|
%% Ensure the zomp home directory exists and is populated.
|
|
%% Every entry function should run this initially.
|
|
|
|
ensure_zomp_home() ->
|
|
ZompDir = zomp_dir(),
|
|
case filelib:is_dir(ZompDir) of
|
|
true -> ok;
|
|
false -> setup(ZompDir)
|
|
end.
|
|
|
|
|
|
-spec setup(ZompDir :: file:filename()) -> ok.
|
|
|
|
setup(ZompDir) ->
|
|
{ok, CWD} = file:get_cwd(),
|
|
ok = force_dir(ZompDir),
|
|
ok = file:set_cwd(ZompDir),
|
|
SubDirs = ["tmp", "key", "var", "lib", "zrp", "etc"],
|
|
ok = lists:foreach(fun file:make_dir/1, SubDirs),
|
|
ok = setup_otpr(),
|
|
ok = log(info, "Zomp userland directory initialized."),
|
|
file:set_cwd(CWD).
|
|
|
|
|
|
-spec setup_otpr() -> ok.
|
|
|
|
setup_otpr() ->
|
|
log(info, "Here should pull otpr.0.zrf and install it...").
|
|
|
|
|
|
-spec zomp_dir() -> file:filename().
|
|
%% @private
|
|
%% Check the host OS and return the absolute path to the zomp filesystem root.
|
|
|
|
zomp_dir() ->
|
|
case os:type() of
|
|
{unix, _} ->
|
|
Home = os:getenv("HOME"),
|
|
Dir = ".zomp",
|
|
filename:join(Home, Dir);
|
|
{win32, _} ->
|
|
Drive = os:getenv("HOMEDRIVE"),
|
|
Path = os:getenv("HOMEPATH"),
|
|
Dir = "zomp",
|
|
filename:join([Drive, Path, Dir])
|
|
end.
|
|
|
|
|
|
-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) ->
|
|
PackageHome = package_home(PackageID),
|
|
PackageData = package_dir("var", PackageID),
|
|
PackageConf = package_dir("etc", PackageID),
|
|
Dirs = [PackageHome, PackageData, PackageConf],
|
|
ok = lists:foreach(fun force_dir/1, Dirs),
|
|
log(info, "Created dirs:~n\t~ts~n\t~ts~n\t~ts", Dirs).
|
|
|
|
|
|
-spec package_home(PackageID) -> PackageHome
|
|
when PackageID :: package_id(),
|
|
PackageHome :: file:filename().
|
|
%% @private
|
|
%% Accept an PackageID and return the installation directory for the indicated
|
|
%% application.
|
|
%% NOTE:
|
|
%% This system does NOT anticipate symlinks of incomplete versions to their latest
|
|
%% installed version (for example, an incomplete `{1, 2, z}' resolving to a symlink
|
|
%% `lib/foo-bar-1.2' which is always updated to point to the latest version 1.2.x).
|
|
|
|
package_home(PackageID) ->
|
|
filename:join([zomp_dir(), "lib", package_string(PackageID)]).
|
|
|
|
|
|
-spec package_dir(Prefix, PackageID) -> PackageDataDir
|
|
when Prefix :: string(),
|
|
PackageID :: package_id(),
|
|
PackageDataDir :: file:filename().
|
|
%% @private
|
|
%% Create an absolute path to an application directory prefixed by the inclued argument.
|
|
|
|
package_dir(Prefix, {Realm, Name, _}) ->
|
|
PackageName = Realm ++ "-" ++ Name,
|
|
filename:join([zomp_dir(), Prefix, PackageName]).
|
|
|
|
|
|
-spec force_dir(Path) -> Result
|
|
when Path :: file:filename(),
|
|
Result :: ok
|
|
| {error, file:posix()}.
|
|
%% @private
|
|
%% Guarantee a directory path is created if it is possible to create or if it already
|
|
%% exists.
|
|
|
|
force_dir(Path) ->
|
|
case filelib:is_dir(Path) of
|
|
true -> ok;
|
|
false -> filelib:ensure_dir(filename:join(Path, "foo"))
|
|
end.
|
|
|
|
|
|
-spec realm_conf(Realm) -> RealmFileName
|
|
when Realm :: string(),
|
|
RealmFileName :: file:filename().
|
|
%% @private
|
|
%% Take a realm name, and return the name of the realm filename that would result.
|
|
|
|
realm_conf(Realm) ->
|
|
Realm ++ ".realm".
|
|
|
|
|
|
-spec load_realm_conf(Realm) -> RealmConf | no_return()
|
|
when Realm :: realm(),
|
|
RealmConf :: list().
|
|
%% @private
|
|
%% Load the config for the given realm or halt with an error.
|
|
|
|
load_realm_conf(Realm) ->
|
|
Path = filename:join(zomp_dir(), realm_conf(Realm)),
|
|
case file:consult(Path) of
|
|
{ok, C} ->
|
|
C;
|
|
{error, enoent} ->
|
|
ok = log(warning, "Realm ~tp is not configured.", [Realm]),
|
|
halt(1)
|
|
end.
|
|
|
|
|
|
%%% 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 = "~n"
|
|
"zx~n"
|
|
"~n"
|
|
"Usage: zx [command] [object] [args]~n"
|
|
"~n"
|
|
"Examples:~n"
|
|
" zx help~n"
|
|
" zx run PackageID [Args]~n"
|
|
" zx init Type PackageID~n"
|
|
" zx install PackageID~n"
|
|
" zx set dep PackageID~n"
|
|
" zx set version Version~n"
|
|
" zx list realms~n"
|
|
" zx list packages Realm~n"
|
|
" zx list versions PackageName~n"
|
|
" zx list pending PackageName~n"
|
|
" zx list resigns Realm~n"
|
|
" zx add realm RealmFile~n"
|
|
" zx add package PackageName~n"
|
|
" zx add packager PackageName~n"
|
|
" zx add maintainer PackageName~n"
|
|
" zx review PackageID~n"
|
|
" zx approve PackageID~n"
|
|
" zx reject PackageID~n"
|
|
" zx resign PackageID~n"
|
|
" zx drop dep PackageID~n"
|
|
" zx drop key Realm KeyName~n"
|
|
" zx drop realm Realm~n"
|
|
" zx verup Level~n"
|
|
" zx runlocal [Args]~n"
|
|
" zx package [Path]~n"
|
|
" zx submit Path~n"
|
|
" zx create user Realm Username~n"
|
|
" zx create keypair~n"
|
|
" zx create plt~n"
|
|
" zx create realm~n"
|
|
" zx create realmfile Realm~n"
|
|
" zx create sysop~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 .zrp file~n"
|
|
"~n",
|
|
io:format(T).
|
|
|
|
|
|
|
|
%%% Error exits
|
|
|
|
|
|
-spec error_exit(Error, Path, Line) -> no_return()
|
|
when Error :: term(),
|
|
Path :: file:filename(),
|
|
Line :: non_neg_integer().
|
|
%% @private
|
|
%% Format an error message in a way that makes it easy to locate.
|
|
|
|
error_exit(Error, Path, Line) ->
|
|
File = filename:basename(Path),
|
|
ok = log(error, "~ts:~tp: ~tp", [File, Line, Error]),
|
|
halt(1).
|
|
|
|
|
|
-spec error_exit(Format, Args, Path, Line) -> no_return()
|
|
when Format :: string(),
|
|
Args :: [term()],
|
|
Path :: file:filename(),
|
|
Line :: non_neg_integer().
|
|
%% @private
|
|
%% Format an error message in a way that makes it easy to locate.
|
|
|
|
error_exit(Format, Args, Path, Line) ->
|
|
File = filename:basename(Path),
|
|
ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]),
|
|
halt(1).
|
|
|
|
|
|
|
|
%%% Logger
|
|
|
|
-spec log(Level, Format) -> ok
|
|
when Level :: info
|
|
| warning
|
|
| error,
|
|
Format :: string().
|
|
%% @private
|
|
%% @equiv log(Level, Format, [])
|
|
|
|
log(Level, Format) ->
|
|
log(Level, Format, []).
|
|
|
|
|
|
-spec log(Level, Format, Args) -> ok
|
|
when Level :: info
|
|
| warning
|
|
| error,
|
|
Format :: string(),
|
|
Args :: [term()].
|
|
%% @private
|
|
%% A logging abstraction to hide whatever logging back end is actually in use.
|
|
%% Format must adhere to Erlang format string rules, and the arity of Args must match
|
|
%% the provided format.
|
|
|
|
log(Level, Format, Args) ->
|
|
Tag =
|
|
case Level of
|
|
info -> "[INFO]";
|
|
warning -> "[WARNING]";
|
|
error -> "[ERROR]"
|
|
end,
|
|
io:format("~s ~p: " ++ Format ++ "~n", [Tag, self() | Args]).
|