okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.peterl: replace deprecated catch expressions with try for OTP 2931ff834

main
9.0 KiB237 linesraw
1%% A fake usage CLI, a local HTTP endpoint and a command whose whole job is to
2%% dump the environment it ran with: the three things usage_feed's loop has to
3%% get right (envelope on stdin, responses replayed, allowlist on commands)
4%% without a real provider, a real credential or the network.
5%%
6%% ALBEDO_USAGE_CORE points the driver's binary resolution at the fake, so the
7%% daemon is never involved: no route exercises this loop end to end yet.
8-module(albedo_usage_test_support).
9
10-export([start/0, stop/1, envelope/1, env_dump/1, request_line/1,
11         break_binary/1, mono_ms/0, trickle_pid_gone/1]).
12
13%% eunit runs a module's tests in parallel ({inparallel, _} reaches into the
14%% module), and ALBEDO_USAGE_CORE is one global environment variable, so the
15%% fakes must not overlap. A registered name is the lock: it is held from
16%% start to stop, and the death of a crashed test process releases it.
17-define(LOCK, albedo_usage_fake).
18
19start() ->
20    await_lock(),
21    Dir = filename:join(tmp(), "albedo-usage-" ++ os:getpid() ++ "-"
22                              ++ integer_to_list(erlang:unique_integer([positive]))),
23    ok = file:make_dir(Dir),
24    {ok, Listen} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}, {packet, raw}]),
25    {ok, {_Address, Port}} = inet:sockname(Listen),
26    Server = spawn(fun() -> serve(Listen, []) end),
27    Url = io_lib:format("http://127.0.0.1:~B/usage", [Port]),
28    Usage = filename:join(Dir, "usage"),
29    Dump = filename:join(Dir, "env-dump.sh"),
30    Trickle = filename:join(Dir, "trickle.sh"),
31    write(Usage, fake_usage(Url, Dump, Trickle, filename:join(Dir, "envelope.txt"))),
32    write(Dump, ["#!/bin/sh\nenv >", filename:join(Dir, "env.txt"), "\necho dumped\n"]),
33    %% Prints its own pid, then a line forever: a command that always has
34    %% output but never exits, for the deadline-kill test.
35    write(Trickle, ["#!/bin/sh\necho $$ >", filename:join(Dir, "trickle.pid"),
36                    "\nwhile true; do\n  echo tick\n  sleep 0.2\ndone\n"]),
37    ok = file:change_mode(Usage, 8#755),
38    ok = file:change_mode(Dump, 8#755),
39    ok = file:change_mode(Trickle, 8#755),
40    os:putenv("ALBEDO_USAGE_CORE", Usage),
41    os:putenv("ALBEDO_USAGE_TEST_CANARY", "leak"),
42    {ok, #{dir => Dir, listen => Listen, server => Server,
43           envelope => filename:join(Dir, "envelope.txt"),
44           env => filename:join(Dir, "env.txt"),
45           trickle_pid => filename:join(Dir, "trickle.pid")}}.
46
47stop(Ref) ->
48    exit(maps:get(server, Ref), kill),
49    gen_tcp:close(maps:get(listen, Ref)),
50    os:unsetenv("ALBEDO_USAGE_CORE"),
51    os:unsetenv("ALBEDO_USAGE_TEST_CANARY"),
52    _ = file:del_dir_r(maps:get(dir, Ref)),
53    _ = try unregister(?LOCK) catch error:badarg -> false end,
54    nil.
55
56await_lock() ->
57    case whereis(?LOCK) of
58        Pid when is_pid(Pid) ->
59            Ref = erlang:monitor(process, Pid),
60            receive
61                {'DOWN', Ref, process, Pid, _} -> ok
62            after 15000 ->
63                exit(usage_fake_lock_timeout)
64            end,
65            await_lock();
66        undefined ->
67            try register(?LOCK, self()) of
68                true -> ok
69            catch
70                _:_ -> await_lock()
71            end
72    end.
73
74envelope(Ref) ->
75    read(maps:get(envelope, Ref)).
76
77env_dump(Ref) ->
78    read(maps:get(env, Ref)).
79
80request_line(Ref) ->
81    Server = maps:get(server, Ref),
82    Server ! {requests, self()},
83    receive
84        {requests, Server, Lines} -> Lines
85    after 2000 -> []
86    end.
87
88%% Points resolution at a path that does not exist: the override must be
89%% authoritative, never silently falling back to PATH.
90break_binary(Ref) ->
91    os:putenv("ALBEDO_USAGE_CORE", filename:join(maps:get(dir, Ref), "not-there")),
92    nil.
93
94mono_ms() ->
95    erlang:monotonic_time(millisecond).
96
97%% The trickle command is killed on timeout, not orphaned: its pid stops
98%% answering signal 0. The beam reaps its port children asynchronously, so
99%% the check polls briefly rather than asserting the very first probe.
100trickle_pid_gone(Ref) ->
101    Path = maps:get(trickle_pid, Ref),
102    case file:read_file(Path) of
103        {ok, Bin} ->
104            Pid = binary_to_integer(string:trim(Bin)),
105            gone(Pid, 20);
106        _ ->
107            false
108    end.
109
110gone(Pid, 0) ->
111    signal(Pid) =/= 0;
112gone(Pid, N) ->
113    case signal(Pid) of
114        0 -> timer:sleep(50), gone(Pid, N - 1);
115        _ -> true
116    end.
117
118%% Exit status of `kill -0 Pid`: 0 while the process exists.
119signal(Pid) ->
120    Kill = case os:find_executable("kill") of
121        false -> erlang:error({missing_test_dependency, "kill executable not found in PATH"});
122        Executable -> Executable
123    end,
124    Port = open_port({spawn_executable, Kill},
125                     [{args, ["-0", integer_to_list(Pid)]}, exit_status, binary, hide]),
126    receive
127        {Port, {exit_status, Status}} -> Status
128    after 1000 ->
129        _ = try port_close(Port) catch _:_ -> ok end,
130        0
131    end.
132
133read(Path) ->
134    case file:read_file(Path) of
135        {ok, Bin} -> Bin;
136        _ -> <<>>
137    end.
138
139write(Path, Content) ->
140    ok = file:write_file(Path, Content).
141
142tmp() ->
143    case os:getenv("TMPDIR") of
144        false -> "/tmp";
145        T -> T
146    end.
147
148%% The fake core speaks the real protocol: one envelope line in (the whole
149%% history, on stdin), one step line out, and the provider name decides what
150%% kind of feed it is. The default feed answers round one with an http request
151%% and a command, and round two with a report built from the http body - so
152%% the loop, the envelope and the command environment are all load-bearing.
153fake_usage(Url, Dump, Trickle, EnvelopeLog) ->
154    ["#!/usr/bin/env python3\n"
155     "import json, sys\n"
156     "line = sys.stdin.readline()\n"
157     "open(", quote(EnvelopeLog), ", 'a').write(line)\n"
158     "envelope = json.loads(line)\n"
159     "provider = envelope.get('provider')\n"
160     "responses = envelope.get('responses', [])\n"
161     "if provider == 'endless':\n"
162     "    step = {'requests': [{'kind': 'command', 'command': 'true', 'args': []}]}\n"
163     "elif provider == 'broken':\n"
164     "    step = {'report': {'limits': [], 'error': 'the provider is unreachable'}}\n"
165     "elif provider == 'trickle':\n"
166     "    if responses:\n"
167     "        status = responses[-1][0]['status']\n"
168     "        step = {'report': {'limits': [], 'error': 'command answered %d' % status}}\n"
169     "    else:\n"
170     "        step = {'requests': [\n"
171     "            {'kind': 'command', 'command': ", quote(Trickle), ", 'args': [],\n"
172     "             'timeoutMs': 1000}]}\n"
173     "elif provider == 'missing-command' and responses:\n"
174     "    status = responses[-1][0]['status']\n"
175     "    step = {'report': {'limits': [], 'error': 'command answered %d' % status}}\n"
176     "elif responses:\n"
177     "    body = json.loads(responses[-1][0]['body'])\n"
178     "    step = {'report': {\n"
179     "        'accountId': 'acct-1', 'email': 'dawn@example.com', 'planType': 'token',\n"
180     "        'limits': [{'id': 'five_hour', 'label': 'Session',\n"
181     "                    'usedPercent': body['pct'], 'windowLabel': '5h window',\n"
182     "                    'windowSeconds': 18000, 'resetsAt': 1893456000000,\n"
183     "                    'status': 'ok'}],\n"
184     "        'error': None}}\n"
185     "elif provider == 'missing-command':\n"
186     "    step = {'requests': [\n"
187     "        {'kind': 'command', 'command': 'no-such-binary-xyz', 'args': []}]}\n"
188     "else:\n"
189     "    step = {'requests': [\n"
190     "        {'kind': 'http', 'method': 'GET', 'url': ", quote(Url), ",\n"
191     "         'headers': [['accept', 'application/json']]},\n"
192     "        {'kind': 'command', 'command': ", quote(Dump), ", 'args': [],\n"
193     "         'timeoutMs': 5000}]}\n"
194     "print(json.dumps(step))\n"].
195
196quote(Text) ->
197    [$', Text, $'].
198
199serve(Listen, Acc) ->
200    receive
201        {requests, From} ->
202            From ! {requests, self(), lists:reverse(Acc)}
203    after 0 ->
204        ok
205    end,
206    case gen_tcp:accept(Listen, 200) of
207        {ok, Socket} ->
208            case read_head(Socket, <<>>) of
209                {ok, Head} ->
210                    [RequestLine | _] = binary:split(Head, <<"\r\n">>),
211                    Body = <<"{\"pct\":37}">>,
212                    ok = gen_tcp:send(Socket, [
213                        <<"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n">>,
214                        <<"content-length: ">>, integer_to_binary(byte_size(Body)),
215                        <<"\r\n\r\n">>, Body]),
216                    gen_tcp:close(Socket),
217                    serve(Listen, [RequestLine | Acc]);
218                _ ->
219                    gen_tcp:close(Socket),
220                    serve(Listen, Acc)
221            end;
222        {error, timeout} ->
223            serve(Listen, Acc);
224        _ ->
225            ok
226    end.
227
228read_head(Socket, Acc) ->
229    case binary:match(Acc, <<"\r\n\r\n">>) of
230        nomatch ->
231            case gen_tcp:recv(Socket, 0, 5000) of
232                {ok, Bytes} -> read_head(Socket, <<Acc/binary, Bytes/binary>>);
233                Error -> Error
234            end;
235        _ ->
236            {ok, Acc}
237    end.