okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
31ff834
main
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 , 11break_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 () -> 20await_lock (), 21Dir = filename : join ( tmp (), "albedo-usage-" ++ os : getpid () ++ "-" 22++ integer_to_list ( erlang : unique_integer ([ positive ]))), 23ok = 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 ), 26Server = spawn ( fun () -> serve ( Listen , []) end ), 27Url = io_lib : format ( "http://127.0.0.1:~B/usage" , [ Port ]), 28Usage = filename : join ( Dir , "usage" ), 29Dump = filename : join ( Dir , "env-dump.sh" ), 30Trickle = filename : join ( Dir , "trickle.sh" ), 31write ( Usage , fake_usage ( Url , Dump , Trickle , filename : join ( Dir , "envelope.txt" ))), 32write ( 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. 35write ( Trickle , [ "#!/bin/sh\necho $$ >" , filename : join ( Dir , "trickle.pid" ), 36"\nwhile true; do\n echo tick\n sleep 0.2\ndone\n" ]), 37ok = file : change_mode ( Usage , 8#755 ), 38ok = file : change_mode ( Dump , 8#755 ), 39ok = file : change_mode ( Trickle , 8#755 ), 40os : putenv ( "ALBEDO_USAGE_CORE" , Usage ), 41os : putenv ( "ALBEDO_USAGE_TEST_CANARY" , "leak" ), 42{ ok , # { dir =>Dir , listen =>Listen , server =>Server , 43envelope =>filename : join ( Dir , "envelope.txt" ), 44env =>filename : join ( Dir , "env.txt" ), 45trickle_pid =>filename : join ( Dir , "trickle.pid" )}}. 46 47stop ( Ref ) -> 48exit ( maps : get ( server , Ref ), kill ), 49gen_tcp : close ( maps : get ( listen , Ref )), 50os : unsetenv ( "ALBEDO_USAGE_CORE" ), 51os : unsetenv ( "ALBEDO_USAGE_TEST_CANARY" ), 52_ = file : del_dir_r ( maps : get ( dir , Ref )), 53_ = try unregister ( ?LOCK ) catch error : badarg -> false end , 54nil . 55 56await_lock () -> 57case whereis ( ?LOCK ) of 58Pid when is_pid ( Pid ) -> 59Ref = erlang : monitor ( process , Pid ), 60receive 61{ 'DOWN' , Ref , process , Pid , _ } -> ok 62after 15000 -> 63exit ( usage_fake_lock_timeout ) 64end , 65await_lock (); 66undefined -> 67try register ( ?LOCK , self ()) of 68true -> ok 69catch 70_ : _ -> await_lock () 71end 72end . 73 74envelope ( Ref ) -> 75read ( maps : get ( envelope , Ref )). 76 77env_dump ( Ref ) -> 78read ( maps : get ( env , Ref )). 79 80request_line ( Ref ) -> 81Server = maps : get ( server , Ref ), 82Server ! { requests , self ()}, 83receive 84{ requests , Server , Lines } -> Lines 85after 2000 -> [] 86end . 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 ) -> 91os : putenv ( "ALBEDO_USAGE_CORE" , filename : join ( maps : get ( dir , Ref ), "not-there" )), 92nil . 93 94mono_ms () -> 95erlang : 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 ) -> 101Path = maps : get ( trickle_pid , Ref ), 102case file : read_file ( Path ) of 103{ ok , Bin } -> 104Pid = binary_to_integer ( string : trim ( Bin )), 105gone ( Pid , 20 ); 106_ -> 107false 108end . 109 110gone ( Pid , 0 ) -> 111signal ( Pid ) =/= 0 ; 112gone ( Pid , N ) -> 113case signal ( Pid ) of 1140 -> timer : sleep ( 50 ), gone ( Pid , N - 1 ); 115_ -> true 116end . 117 118%% Exit status of `kill -0 Pid`: 0 while the process exists. 119signal ( Pid ) -> 120Kill = case os : find_executable ( "kill" ) of 121false -> erlang : error ({ missing_test_dependency , "kill executable not found in PATH" }); 122Executable -> Executable 123end , 124Port = open_port ({ spawn_executable , Kill }, 125[{ args , [ "-0" , integer_to_list ( Pid )]}, exit_status , binary , hide ]), 126receive 127{ Port , { exit_status , Status }} -> Status 128after 1000 -> 129_ = try port_close ( Port ) catch _ : _ -> ok end , 1300 131end . 132 133read ( Path ) -> 134case file : read_file ( Path ) of 135{ ok , Bin } -> Bin ; 136_ -> <<>> 137end . 138 139write ( Path , Content ) -> 140ok = file : write_file ( Path , Content ). 141 142tmp () -> 143case os : getenv ( "TMPDIR" ) of 144false -> "/tmp" ; 145T -> T 146end . 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 ) -> 200receive 201{ requests , From } -> 202From ! { requests , self (), lists : reverse ( Acc )} 203after 0 -> 204ok 205end , 206case gen_tcp : accept ( Listen , 200 ) of 207{ ok , Socket } -> 208case read_head ( Socket , <<>>) of 209{ ok , Head } -> 210[ RequestLine | _ ] = binary : split ( Head , << "\r\n" >>), 211Body = << "{\"pct\":37}" >>, 212ok = 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 ]), 216gen_tcp : close ( Socket ), 217serve ( Listen , [ RequestLine | Acc ]); 218_ -> 219gen_tcp : close ( Socket ), 220serve ( Listen , Acc ) 221end ; 222{ error , timeout } -> 223serve ( Listen , Acc ); 224_ -> 225ok 226end . 227 228read_head ( Socket , Acc ) -> 229case binary : match ( Acc , << "\r\n\r\n" >>) of 230nomatch -> 231case gen_tcp : recv ( Socket , 0 , 5000 ) of 232{ ok , Bytes } -> read_head ( Socket , << Acc / binary , Bytes / binary >>); 233Error -> Error 234end ; 235_ -> 236{ ok , Acc } 237end .