diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 189105d..779f162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,11 @@ on: [push, pull_request] jobs: blackbox: - uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/black-box-matrix.yml@next coverage: - uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/coverage-matrix.yml@next secrets: inherit psalm: - uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@main + uses: innmind/github-workflows/.github/workflows/psalm-matrix.yml@next cs: - uses: innmind/github-workflows/.github/workflows/cs.yml@main - with: - php-version: '8.2' + uses: innmind/github-workflows/.github/workflows/cs.yml@next diff --git a/README.md b/README.md index f567c18..666ab25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # testing -[![Build Status](https://github.com/innmind/testing/workflows/CI/badge.svg?branch=main)](https://github.com/innmind/testing/actions?query=workflow%3ACI) +[![Build Status](https://github.com/Innmind/testing/actions/workflows/ci.yml/badge.svg)](https://github.com/Innmind/testing/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/innmind/testing/branch/develop/graph/badge.svg)](https://codecov.io/gh/innmind/testing) [![Type Coverage](https://shepherd.dev/github/innmind/testing/coverage.svg)](https://shepherd.dev/github/innmind/testing) diff --git a/blackbox.php b/blackbox.php index ff9d705..2588bdc 100644 --- a/blackbox.php +++ b/blackbox.php @@ -11,7 +11,7 @@ Application::new($argv) ->when( - \get_env('ENABLE_COVERAGE') !== false, + \getenv('ENABLE_COVERAGE') !== false, static fn(Application $app) => $app ->scenariiPerProof(1) ->codeCoverage( diff --git a/composer.json b/composer.json index 7e065fd..b2edc94 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,42 @@ "issues": "http://github.com/innmind/testing/issues" }, "require": { - "php": "~8.2" + "php": "~8.4", + "innmind/foundation": "dev-next", + "innmind/immutable": "dev-next", + "innmind/mutable": "dev-next", + "innmind/http": "dev-next", + "innmind/operating-system": "dev-next", + "innmind/reflection": "dev-next", + "innmind/math": "dev-next", + "innmind/xml": "dev-next", + "innmind/html": "dev-next", + "innmind/json": "dev-next", + "innmind/hash": "dev-next", + "innmind/encoding": "dev-next", + "innmind/specification": "~4.1", + "innmind/http-transport": "dev-next", + "innmind/filesystem": "dev-next", + "innmind/file-watch": "dev-next", + "innmind/time-continuum": "dev-next", + "innmind/time-warp": "dev-next", + "innmind/server-control": "dev-next", + "innmind/server-status": "dev-next", + "innmind/url": "dev-next", + "innmind/validation": "dev-next", + "innmind/media-type": "dev-next", + "innmind/io": "dev-next", + "innmind/ip": "dev-next", + "innmind/http-server": "dev-next", + "innmind/cli": "dev-next", + "innmind/router": "dev-next", + "innmind/async": "dev-next", + "innmind/signals": "dev-next", + "innmind/url-template": "dev-next", + "innmind/stack-trace": "dev-next", + "innmind/graphviz": "dev-next", + "innmind/colour": "dev-next", + "formal/access-layer": "dev-next" }, "autoload": { "psr-4": { diff --git a/proofs/.gitkeep b/proofs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/proofs/cluster.php b/proofs/cluster.php new file mode 100644 index 0000000..6c0fbb8 --- /dev/null +++ b/proofs/cluster.php @@ -0,0 +1,990 @@ +listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + ))), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Cluster time can be fast forwarded', + given( + PointInTime::any(), + Set::integers()->between(1, 1_000), + ), + static function($assert, $start, $seconds) { + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->process() + ->halt(Period::second($seconds)) + ->map(static fn() => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $assert + ->time(static function() use ($assert, $cluster, $start, $seconds) { + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::second($seconds)) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }) + ->inLessThan() + ->seconds(1); + }, + ); + + yield proof( + 'Cluster time can be sped up', + given( + PointInTime::any(), + Set::integers()->between(2, 10), + ), + static function($assert, $start, $speed) { + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->process() + ->halt(Period::second(1)) + ->map(static fn() => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->speedUpTimeBy($speed) + ->add($local) + ->boot(); + + $assert + ->time(static function() use ($assert, $cluster, $start, $speed) { + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::second($speed)) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }) + ->inLessThan() + ->seconds(1); + }, + ); + + yield proof( + 'HTTP app can execute simulated process on the same machine', + given( + PointInTime::any(), + ), + static function($assert, $start) { + $called = false; + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + $os, + ) use ($assert, &$called) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + $called = true; + + return $builder->success([[ + $os->clock()->now()->format(Format::iso8601()), + 'output', + ]]); + }), + ) + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks( + $os + ->control() + ->processes() + ->execute( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()), + ), + ))), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->true($called); + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Machine can make HTTP calls to another machine', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $input, $output) { + $remote = Machine::new('remote.dev') + ->listen( + Machine\HTTP::of(static function($request) use ($assert, $input, $output) { + $assert->same($input, $request->body()->toString()); + + return Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($output), + )); + }), + ); + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::post, + ProtocolVersion::v11, + null, + Content::ofString($input), + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $output, + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Streamed HTTP responses are accessed only once over the network', + given( + Set::strings(), + ), + static function($assert, $output) { + $called = 0; + $remote = Machine::new('remote.dev') + ->listen( + Machine\HTTP::of(static function($request) use ($output, &$called) { + return Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks(Sequence::lazy(static function() use ($output, &$called) { + ++$called; + + yield Str::of($output); + })), + )); + }), + ); + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($body->toString().$body->toString()), + )), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same(1, $called); + $assert->same( + $output.$output, + $response->body()->toString(), + ); + }, + ); + + yield test( + 'Machines do not use the same operating system instance', + static function($assert) { + $remote = Machine::new('remote.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\spl_object_hash($os)), + ))), + ); + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\spl_object_hash($os).'|'.$body->toString()), + )), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$local, $remote] = \explode('|', $response); + + $assert + ->expected($local) + ->not() + ->same($remote); + }, + ); + + yield test( + 'Machine get an error when accessing unknown machine over HTTP', + static function($assert) { + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->match( + static fn($success) => Attempt::result($success->response()), + static fn($error) => match (true) { + $error instanceof ConnectionFailed => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($error->reason()), + )), + default => Attempt::error(new RuntimeException), + }, + ), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + 'Could not resolve host: remote.dev', + $response->body()->toString(), + ); + }, + ); + + yield test( + 'Cluster get an error when accessing unknown machine over SSH', + static function($assert) { + $cluster = Cluster::new()->boot(); + + $e = $cluster + ->ssh('local.dev') + ->match( + static fn() => null, + static fn($e) => $e, + ); + + $assert + ->object($e) + ->instance(CouldNotResolveHost::class); + }, + ); + + yield proof( + 'Time can drift between machines', + given( + // Below the second of drift it's hard to assert it's correctly + // applied as the clock still advances. So doing time math at this + // level of precision regularly fails with an off by one error. + Set::either( + Set::integers()->between(1_000, 3_000), + Set::integers()->between(-3_000, -1_000), + ), + ), + static function($assert, $drift) { + $remote = Machine::new('remote.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $request->body()->toString(), + $os->clock()->now()->format(Format::iso8601()), + )), + ))), + ); + $local = Machine::new('local.dev') + ->driftClockBy(Machine\Clock\Drift::of(0, $drift)) + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::post, + ProtocolVersion::v11, + Headers::of( + Date::of($os->clock()->now()), // to force accessing the second drift + ), + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $body->toString(), + $os->clock()->now()->format(Format::iso8601()), + )), + )), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$drifted, $remote, $resynced] = \explode('|', $response); + // remove last millisecond as the execution of the code can elapse + // over 2 miliseconds. + $drifted = \substr($drifted, 0, -1); + $remote = \substr($remote, 0, -1); + $resynced = \substr($resynced, 0, -1); + + $assert + ->expected($drifted) + ->not() + ->same($remote); + $assert->same($remote, $resynced); + }, + ); + + yield proof( + 'Network latencies', + given( + PointInTime::any(), + // Below the second of latency it's hard to assert it's correctly + // applied as the clock still advances. So doing time math at this + // level of precision regularly fails with an off by one error. + Set::integers()->between(1_000, 3_000), + Set::integers()->between(1_000, 3_000), + ), + static function($assert, $start, $in, $out) { + $remote = Machine::new('remote.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + ))), + ); + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => $os + ->remote() + ->http()(Request::of( + Url::of('http://remote.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->attempt(static fn() => new RuntimeException('Failed to access remote server')) + ->map(static fn($success) => $success->response()->body()) + ->map(static fn($body) => Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString($os->clock()->now()->format(Format::iso8601())), + )), + ), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->startClockAt($start) + ->withNetworkLatency(Network\Latency::of($in, $out)) + ->boot(); + + $now = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->goForward(Period::millisecond($in + $in + $out)) // 2 $in as we make call local.dev over http + ->format(Format::iso8601()), + $now, + ); + }, + ); + + yield proof( + 'Machine OS is configurable', + given( + // France was on UTC time for part of 20th century + PointInTime::after('2000-01-01'), + ), + static function($assert, $start) { + $start = $start->goBack(Period::millisecond($start->millisecond()->toInt())); + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\sprintf( + '%s|%s', + $os->clock()->now()->format(Format::iso8601()), + $os->clock()->now()->changeOffset(Offset::utc())->format(Format::iso8601()), + )), + ))), + ) + ->configureOperatingSystem( + static fn($config) => $config->mapClock( + static fn($clock) => $clock->switch( + static fn($timezones) => $timezones->europe()->paris(), + ), + ), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap() + ->body() + ->toString(); + [$paris, $utc] = \explode('|', $response); + + $assert + ->expected( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + ) + ->same($utc) + ->not() + ->same($paris); + }, + ); + + yield proof( + 'HTTP app has access to machine environment variables', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $key, $value) { + $local = Machine::new('local.dev') + ->listen( + Machine\HTTP::of(static fn($request, $_, $environment) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofString(\implode( + '', + $environment + ->map(static fn($key, $value) => $key.$value) + ->values() + ->toList(), + )), + ))), + ) + ->withEnvironment($key, $value); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $key.$value, + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'CLI app has access to machine environment variables', + given( + Set::strings(), + Set::strings(), + ), + static function($assert, $key, $value) { + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + $_, + $environment, + ) use ($assert) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + \implode( + '', + $environment + ->map(static fn($key, $value) => $key.$value) + ->values() + ->toList(), + ), + 'output', + ]]); + }), + ) + ->listen( + Machine\HTTP::of(static fn($request, $os) => Attempt::result(Response::of( + StatusCode::ok, + $request->protocolVersion(), + null, + Content::ofChunks( + $os + ->control() + ->processes() + ->execute( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()), + ), + ))), + ) + ->withEnvironment($key, $value); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $response = $cluster + ->http(Request::of( + Url::of('http://local.dev/'), + Method::get, + ProtocolVersion::v11, + )) + ->unwrap(); + + $assert->same( + $key.$value, + $response->body()->toString(), + ); + }, + ); + + yield proof( + 'Machine is accessible over ssh', + given( + PointInTime::any(), + ), + static function($assert, $start) { + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + $os, + ) use ($assert) { + $assert->same( + "foo 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + $os->clock()->now()->format(Format::iso8601()), + 'output', + ]]); + }), + ); + $cluster = Cluster::new() + ->startClockAt($start) + ->add($local) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run( + Command::foreground('foo') + ->withArgument('display') + ->withOption('option'), + )) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $start + ->changeOffset(Offset::utc()) + ->format(Format::iso8601()), + $output, + ); + }, + ); + + yield proof( + 'Machine can execute processes on another machine over ssh', + given( + Set::strings(), + ), + static function($assert, $expected) { + $remote = Machine::new('remote.dev') + ->install( + 'bar', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $expected) { + $assert->same( + "bar 'display' '--option'", + $command->toString(), + ); + + return $builder->success([[ + $expected, + 'output', + ]]); + }), + ); + $local = Machine::new('local.dev') + ->install( + 'foo', + Machine\CLI::of(static function( + $_, + $builder, + $os, + ) use ($assert) { + $output = $os + ->remote() + ->ssh(Url::of('ssh://watev@remote.dev/')) + ->processes() + ->execute( + Command::foreground('bar') + ->withArgument('display') + ->withOption('option'), + ) + ->unwrap() + ->output() + ->map(static fn($chunk) => [ + $chunk->data()->toString(), + $chunk->type()->name, + ]) + ->toList(); + + return $builder->success($output); + }), + ); + $cluster = Cluster::new() + ->add($local) + ->add($remote) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run(Command::foreground('foo'))) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $expected, + $output, + ); + }, + ); + + yield proof( + 'Machine can execute piped commands', + given( + Set::strings(), + Set::strings(), + Set::strings(), + ), + static function($assert, $input, $intermediary, $expected) { + $local = Machine::new('local.dev') + ->install( + 'bar', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $intermediary, $expected) { + $assert->same( + $intermediary, + $command->input()->match( + static fn($input) => $input->toString(), + static fn() => null, + ), + ); + + return $builder->success([[ + $expected, + 'output', + ]]); + }), + ) + ->install( + 'foo', + Machine\CLI::of(static function( + $command, + $builder, + ) use ($assert, $input, $intermediary) { + $assert->same( + $input, + $command->input()->match( + static fn($input) => $input->toString(), + static fn() => null, + ), + ); + + return $builder->success([[ + $intermediary, + 'output', + ]]); + }), + ); + $cluster = Cluster::new() + ->add($local) + ->boot(); + + $output = $cluster + ->ssh('local.dev') + ->flatMap(static fn($run) => $run( + Command::foreground('foo') + ->withInput(Content::ofString($input)) + ->pipe(Command::foreground('bar')), + )) + ->unwrap() + ->output() + ->map(static fn($chunk) => $chunk->data()) + ->fold(new Concat) + ->toString(); + + $assert->same( + $expected, + $output, + ); + }, + ); +}; diff --git a/psalm.xml b/psalm.xml index bd44196..38edc2c 100644 --- a/psalm.xml +++ b/psalm.xml @@ -14,4 +14,7 @@ + + + diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Cluster.php b/src/Cluster.php new file mode 100644 index 0000000..3aa5365 --- /dev/null +++ b/src/Cluster.php @@ -0,0 +1,126 @@ + $machines + * @param ?int<2, 10> $clockSpeed + */ + private function __construct( + private ?PointInTime $start, + private Set $machines, + private Network\Latency $latency, + private ?int $clockSpeed, + ) { + } + + /** + * @psalm-pure + */ + #[\NoDiscard] + public static function new(): self + { + return new self( + null, + Set::of(), + Network\Latency::of(), + null, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function startClockAt(PointInTime $date): self + { + return new self( + $date, + $this->machines, + $this->latency, + $this->clockSpeed, + ); + } + + /** + * @psalm-mutation-free + * + * @param int<2, 10> $speed + */ + #[\NoDiscard] + public function speedUpTimeBy(int $speed): self + { + return new self( + $this->start, + $this->machines, + $this->latency, + $speed, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function add(Machine $machine): self + { + return new self( + $this->start, + ($this->machines)($machine), + $this->latency, + $this->clockSpeed, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function withNetworkLatency(Network\Latency $latency): self + { + return new self( + $this->start, + $this->machines, + $latency, + $this->clockSpeed, + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(self): self $map + */ + #[\NoDiscard] + public function map(callable $map): self + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this); + } + + /** + * @internal + */ + #[\NoDiscard] + public function boot(): Simulation\Cluster + { + $ntp = Simulation\NTPServer::new( + $this->start, + $this->clockSpeed, + ); + $network = Simulation\Network::new($ntp, $this->latency); + $_ = $this->machines->foreach( + static fn($machine) => $machine->boot($network), + ); + + return Simulation\Cluster::new($network); + } +} diff --git a/src/Exception/ConnectionTimeout.php b/src/Exception/ConnectionTimeout.php new file mode 100644 index 0000000..fd99612 --- /dev/null +++ b/src/Exception/ConnectionTimeout.php @@ -0,0 +1,11 @@ + $domains + * @param Map $executables + * @param Map, Machine\HTTP> $http + * @param Map $environment + * @param \Closure(Config): Config $configureOS + */ + private function __construct( + private array $domains, + private Map $executables, + private Map $http, + private Map $environment, + private Machine\Clock\Drift $drift, + private \Closure $configureOS, + ) { + } + + /** + * @psalm-pure + * @no-named-arguments + */ + #[\NoDiscard] + public static function new( + string $domain, + string ...$domains, + ): self { + return new self( + [$domain, ...$domains], + Map::of(), + Map::of(), + Map::of(), + Machine\Clock\Drift::of(), + static fn(Config $config) => $config, + ); + } + + /** + * @psalm-mutation-free + * + * @param non-empty-string $executable + */ + #[\NoDiscard] + public function install( + string $executable, + Machine\CLI $app, + ): self { + return new self( + $this->domains, + ($this->executables)($executable, $app), + $this->http, + $this->environment, + $this->drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + * + * @param ?int<1, max> $port + */ + #[\NoDiscard] + public function listen( + Machine\HTTP $app, + ?int $port = null, + ): self { + return new self( + $this->domains, + $this->executables, + ($this->http)($port, $app), + $this->environment, + $this->drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function withEnvironment(string $key, string $value): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + ($this->environment)($key, $value), + $this->drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + */ + #[\NoDiscard] + public function driftClockBy(Machine\Clock\Drift $drift): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + $this->environment, + $drift, + $this->configureOS, + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(Config): Config $map + */ + #[\NoDiscard] + public function configureOperatingSystem(callable $map): self + { + return new self( + $this->domains, + $this->executables, + $this->http, + $this->environment, + $this->drift, + \Closure::fromCallable($map), + ); + } + + /** + * @psalm-mutation-free + * + * @param callable(self): self $map + */ + #[\NoDiscard] + public function map(callable $map): self + { + /** @psalm-suppress ImpureFunctionCall */ + return $map($this); + } + + // todo add crontab ? + + /** + * @internal + */ + public function boot(Simulation\Network $network): void + { + $network->with( + $this->domains, + Simulation\Machine::new( + $network, + $this->executables, + $this->http, + $this->environment, + $this->drift, + $this->configureOS, + ), + ); + } +} diff --git a/src/Machine/CLI.php b/src/Machine/CLI.php new file mode 100644 index 0000000..f71e28f --- /dev/null +++ b/src/Machine/CLI.php @@ -0,0 +1,52 @@ +): ProcessBuilder $app + */ + private function __construct( + private \Closure $app, // todo support innmind/framework + ) { + } + + /** + * @internal + * + * @param Map $environment + */ + #[\NoDiscard] + public function __invoke( + Command $command, + ProcessBuilder $builder, + OperatingSystem $os, + Map $environment, + ): ProcessBuilder { + return ($this->app)( + $command, + $builder, + $os, + $environment, + ); + } + + /** + * @psalm-pure + * + * @param callable(Command, ProcessBuilder, OperatingSystem, Map): ProcessBuilder $app + */ + #[\NoDiscard] + public static function of(callable $app): self + { + return new self(\Closure::fromCallable($app)); + } +} diff --git a/src/Machine/Clock/Drift.php b/src/Machine/Clock/Drift.php new file mode 100644 index 0000000..8561d02 --- /dev/null +++ b/src/Machine/Clock/Drift.php @@ -0,0 +1,41 @@ + $drifts + */ + private function __construct( + private array $drifts, + ) { + } + + /** + * The drifts are expressed in milliseconds + * + * @psalm-pure + * @no-named-arguments + */ + #[\NoDiscard] + public static function of(int ...$drifts): self + { + return new self($drifts); + } + + /** + * @internal + */ + #[\NoDiscard] + public function asState(): Clock\Drift + { + return Clock\Drift::of($this->drifts); + } +} diff --git a/src/Machine/HTTP.php b/src/Machine/HTTP.php new file mode 100644 index 0000000..930bdb4 --- /dev/null +++ b/src/Machine/HTTP.php @@ -0,0 +1,54 @@ +): Attempt $app + */ + private function __construct( + private \Closure $app, // todo support innmind/framework + ) { + } + + /** + * @internal + * + * @param Map $environment + * + * @return Attempt + */ + #[\NoDiscard] + public function __invoke( + ServerRequest $request, + OperatingSystem $os, + Map $environment, + ): Attempt { + return ($this->app)($request, $os, $environment); + } + + /** + * @psalm-pure + * + * @param callable(ServerRequest, OperatingSystem, Map): Attempt $app + */ + #[\NoDiscard] + public static function of(callable $app): self + { + return new self(\Closure::fromCallable($app)); + } +} diff --git a/src/Machine/ProcessBuilder.php b/src/Machine/ProcessBuilder.php new file mode 100644 index 0000000..5e0a758 --- /dev/null +++ b/src/Machine/ProcessBuilder.php @@ -0,0 +1,159 @@ + $pid + */ + private function __construct( + private int $pid, + private Success|Signaled|TimedOut|Failed $result, + ) { + } + + /** + * @internal + * @psalm-pure + * + * @param int<2, max> $pid + */ + #[\NoDiscard] + public static function new(int $pid): self + { + /** @psalm-suppress ImpureMethodCall todo remove this line when in innmind/server-control */ + return new self($pid, new Success(Sequence::of())); + } + + /** + * @psalm-mutation-free + * + * @param Sequence|list $output + */ + #[\NoDiscard] + public function success(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Success(self::output($output)), + ); + } + + /** + * @psalm-mutation-free + * + * @param Sequence|list $output + */ + #[\NoDiscard] + public function signaled(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Signaled(self::output($output)), + ); + } + + /** + * @psalm-mutation-free + * + * @param Sequence|list $output + */ + #[\NoDiscard] + public function timedOut(Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new TimedOut(self::output($output)), + ); + } + + /** + * @psalm-mutation-free + * + * @param int<1, 255> $exitCode + * @param Sequence|list $output + */ + #[\NoDiscard] + public function failed(int $exitCode = 1, Sequence|array|null $output = null): self + { + return new self( + $this->pid, + new Failed( + new ExitCode($exitCode), + self::output($output), + ), + ); + } + + /** + * @internal + */ + #[\NoDiscard] + public function build(): Process + { + $pid = $this->pid; + $result = $this->result; + + /** + * This a trick to not expose any mock contructor on the Process class. + * + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress MixedReturnStatement + * @psalm-suppress InaccessibleMethod + */ + return (\Closure::bind( + static fn() => new Process(new Mock($pid, $result)), + null, + Process::class, + ))(); + } + + /** + * @psalm-pure + * + * @param Sequence|list $output + * + * @return Sequence + */ + private static function output(Sequence|array|null $output = null): Sequence + { + if (\is_null($output)) { + return Sequence::of(); + } + + if (\is_array($output)) { + return Sequence::of(...$output)->map(static fn($pair) => Chunk::of( + Str::of($pair[0]), + match ($pair[1]) { + 'output' => Type::output, + 'error' => Type::error, + }, + )); + } + + return $output; + } +} diff --git a/src/Network/Latency.php b/src/Network/Latency.php new file mode 100644 index 0000000..3706c42 --- /dev/null +++ b/src/Network/Latency.php @@ -0,0 +1,43 @@ +> $latencies + */ + private function __construct( + private array $latencies, + ) { + } + + /** + * The latencies are expressed in milliseconds + * + * @psalm-pure + * @no-named-arguments + * + * @param int<0, max> ...$latencies + */ + #[\NoDiscard] + public static function of(int ...$latencies): self + { + return new self($latencies); + } + + /** + * @internal + */ + #[\NoDiscard] + public function asState(): Network\Latency + { + return Network\Latency::of($this->latencies); + } +} diff --git a/src/Simulation/Cluster.php b/src/Simulation/Cluster.php new file mode 100644 index 0000000..7f25d59 --- /dev/null +++ b/src/Simulation/Cluster.php @@ -0,0 +1,52 @@ + + */ + #[\NoDiscard] + public function http(Request $request): Attempt + { + return $this->network->http($request); + } + + /** + * @return Attempt> + */ + #[\NoDiscard] + public function ssh(string $host): Attempt + { + return $this + ->network + ->ssh($host) + ->map(static fn($machine) => static fn(Command $command) => $machine->run($command)); + } +} diff --git a/src/Simulation/Machine.php b/src/Simulation/Machine.php new file mode 100644 index 0000000..5b4f347 --- /dev/null +++ b/src/Simulation/Machine.php @@ -0,0 +1,174 @@ + $http + * @param Map $environment + */ + private function __construct( + private Machine\OS $os, + private Network $network, + private Machine\Processes $processes, + private Machine\Clock\Drift $drift, + private Map $http, + private Map $environment, + ) { + } + + /** + * @internal + * + * @param Map $executables + * @param Map, HTTP> $http + * @param Map $environment + * @param \Closure(Config): Config $configureOS + */ + #[\NoDiscard] + public static function new( + Network $network, + Map $executables, + Map $http, + Map $environment, + Clock\Drift $drift, + \Closure $configureOS, + ): self { + $drift = $drift->asState(); + + $os = Machine\OS::new(); + $processes = Machine\Processes::new( + $os, + $executables, + $environment, + ); + $os->boot(OperatingSystem::new($configureOS( + Machine\Config::of( + $network, + $processes, + $drift, + ), + ))); + + return new self( + $os, + $network, + $processes, + $drift, + $http, + $environment, + ); + } + + /** + * @return Attempt + */ + #[\NoDiscard] + public function http(Request $request): Attempt + { + $port = $request->url()->authority()->port(); + + $value = match ($port->equals(Port::none())) { + true => null, + false => $port->value(), + }; + + // Simulate network by preventing iterating over the request/response + // bodies twice. Though this approach prevents streaming, todo use + // `Sequence::defer()` instead ? + + $serverRequest = ServerRequest::of( + $request->url(), + $request->method(), + $request->protocolVersion(), + $request->headers(), + Content::ofChunks( + $request + ->body() + ->chunks() + ->snap(), + ), + // todo parse the content + ); + + return $this + ->http + ->get($value) + ->attempt( + fn() => $this + ->os + ->unwrap() + ->process() + ->halt(Period::minute(1)) // todo make this configurable ? + ->match( + static fn() => new ConnectionTimeout, + static fn($e) => $e, + ), + ) + ->flatMap(fn($app) => $app( + $serverRequest, + $this->os->unwrap(), + $this->environment, + )) + ->map(static fn($response) => Response::of( + $response->statusCode(), + $response->protocolVersion(), + $response->headers(), + Content::ofChunks( + $response + ->body() + ->chunks() + ->snap(), + ), + )); + } + + /** + * @return Attempt + */ + #[\NoDiscard] + public function run(Command|Command\OverSsh $command): Attempt + { + if ($command instanceof Command\OverSsh) { + return $this + ->drift + ->reset($this->network) + ->ssh($command->host()->toString()) + ->flatMap(static fn($machine) => $machine->run($command->command())); + } + + return $this->processes->run($command); + } +} diff --git a/src/Simulation/Machine/Clock/Drift.php b/src/Simulation/Machine/Clock/Drift.php new file mode 100644 index 0000000..f870cfc --- /dev/null +++ b/src/Simulation/Machine/Clock/Drift.php @@ -0,0 +1,68 @@ + $drifts + */ + private function __construct( + private Ring $drifts, + private int $accumulated = 0, + ) { + } + + #[\NoDiscard] + public function __invoke(PointInTime $now): PointInTime + { + return $this + ->drifts + ->pull() + ->map(fn($drift) => $this->accumulated += $drift) + ->match( + static fn($period) => match (true) { + $period === 0 => $now, + $period > 0 => $now->goForward(Period::millisecond($period)), + $period < 0 => $now->goBack(Period::millisecond($period * -1)), + }, + static fn() => $now, + ); + } + + /** + * @psalm-pure + * @internal + * + * @param list $drifts + */ + public static function of(array $drifts): self + { + return new self(Ring::of(...$drifts)); + } + + /** + * Drift reset can only occur when accessing the network to simulate the + * machine is synchronizing with the NTP server. + */ + public function reset(Network $value): Network + { + $this->accumulated = 0; + $this->drifts->reset(); + + return $value; + } +} diff --git a/src/Simulation/Machine/Config.php b/src/Simulation/Machine/Config.php new file mode 100644 index 0000000..6b5e8c9 --- /dev/null +++ b/src/Simulation/Machine/Config.php @@ -0,0 +1,115 @@ +withClock(Clock::via(static fn() => $drift( + $network->ntp()->now(), + ))) + ->haltProcessVia(Halt::via(static fn($period) => Attempt::result( + $network->ntp()->advance($period), + ))) + ->useServerControl(Server::via( + static fn($command) => match (true) { + $command instanceof Command => $processes->run($command), + default => $drift + ->reset($network) + ->ssh($command->host()->toString()) + ->flatMap(static fn($machine) => $machine->run($command->command())), + }, + )) + ->useHttpTransport(Transport::via( + static fn($request) => $drift + ->reset($network) + ->http($request) + ->match( + static fn($response) => match ($response->statusCode()->range()) { + StatusCode\Range::informational => Either::left(new Information( + $request, + $response, + )), + StatusCode\Range::successful => Either::right(new Success( + $request, + $response, + )), + StatusCode\Range::redirection => Either::left(new Redirection( + $request, + $response, + )), + StatusCode\Range::clientError => Either::left(new ClientError( + $request, + $response, + )), + StatusCode\Range::serverError => Either::left(new ServerError( + $request, + $response, + )), + }, + static fn($e) => Either::left(match (true) { + $e instanceof CouldNotResolveHost => new ConnectionFailed( + $request, + $e->getMessage(), + ), + $e instanceof ConnectionTimeout => new Failure( + $request, + 'Connection timeout', + ), + default => new ServerError( + $request, + Response::of( + StatusCode::internalServerError, + $request->protocolVersion(), + ), + ), + }), + ), + )); + } +} diff --git a/src/Simulation/Machine/OS.php b/src/Simulation/Machine/OS.php new file mode 100644 index 0000000..0823202 --- /dev/null +++ b/src/Simulation/Machine/OS.php @@ -0,0 +1,45 @@ +os = $os; + } + + #[\NoDiscard] + public function unwrap(): OperatingSystem + { + if (\is_null($this->os)) { + throw new \LogicException('Machine OS should be booted'); + } + + return $this->os; + } +} diff --git a/src/Simulation/Machine/Processes.php b/src/Simulation/Machine/Processes.php new file mode 100644 index 0000000..9c352ad --- /dev/null +++ b/src/Simulation/Machine/Processes.php @@ -0,0 +1,135 @@ + $executables + * @param Map $environment + * @param int<2, max> $lastPid + */ + private function __construct( + private OS $os, + private Map $executables, + private Map $environment, + private int $lastPid = 2, + ) { + } + + /** + * @psalm-pure + * @internal + * + * @param Map $executables + * @param Map $environment + */ + #[\NoDiscard] + public static function new( + OS $os, + Map $executables, + Map $environment, + ): self { + return new self($os, $executables, $environment); + } + + /** + * @return Attempt + */ + #[\NoDiscard] + public function run(Command $command): Attempt + { + // todo handle timeouts, by hijacking the Process::halt() to make sure + // we don't go over the threshold ? (but how ?) + + return $this->dispatch($command->internal()); + } + + /** + * @return Attempt + */ + private function dispatch(Command\Implementation $command): Attempt + { + if ($command instanceof Command\Definition) { + return $this->doRun($command); + } + + if ($command instanceof Command\Pipe) { + return $this + ->dispatch($command->a()) + ->map( + static fn($process) => $process + ->output() + ->map(static fn($chunk) => $chunk->data()), + ) + ->map(Content::ofChunks(...)) + ->map(static fn($output) => $command->b()->withInput($output)) + ->flatMap($this->dispatch(...)); + } + + throw new \LogicException(\sprintf( + 'Unknown command implementation %s', + $command::class, + )); + } + + /** + * @return Attempt + */ + private function doRun(Command\Definition $command): Attempt + { + $executable = $command->executable(); + /** + * @psalm-suppress MixedAgument + * @psalm-suppress PossiblyNullFunctionCall + * @psalm-suppress InaccessibleMethod + * @var Command + */ + $command = (\Closure::bind( + static fn(): Command => new Command($command), + null, + Command::class, + ))(); + + // todo handle output redirection + // todo add fault injection to simulate inability to spawn a process + // todo build a shell abstraction to allow to change the behaviour ? + + $process = $this + ->executables + ->get($executable) + ->match( + fn($app) => $app( + $command, + ProcessBuilder::new(++$this->lastPid), + $this->os->unwrap(), + $this->environment, + )->build(), + fn() => ProcessBuilder::new(++$this->lastPid) // zsh behaviour + ->failed(127) + ->build(), + ); + + return Attempt::result($process); + } +} diff --git a/src/Simulation/NTPServer.php b/src/Simulation/NTPServer.php new file mode 100644 index 0000000..df8e6b2 --- /dev/null +++ b/src/Simulation/NTPServer.php @@ -0,0 +1,55 @@ + $clockSpeed + */ + #[\NoDiscard] + public static function new( + ?PointInTime $start, + ?int $clockSpeed, + ): self { + return new self(NTPServer\Clock::of( + RealClock::live(), + $start, + $clockSpeed, + )); + } + + #[\NoDiscard] + public function now(): PointInTime + { + return $this->clock->now(); + } + + /** + * Either because a machine halted a process or a network call was made and + * introduced a latency between machines + */ + #[\NoDiscard] + public function advance(Period $period): SideEffect + { + return $this->clock->advance($period); + } +} diff --git a/src/Simulation/NTPServer/Clock.php b/src/Simulation/NTPServer/Clock.php new file mode 100644 index 0000000..198d940 --- /dev/null +++ b/src/Simulation/NTPServer/Clock.php @@ -0,0 +1,84 @@ + $clockSpeed + */ + #[\NoDiscard] + public static function of( + RealClock $clock, + ?PointInTime $now, + ?int $clockSpeed, + ): self { + $realNow = $clock->now(); + $speed = Clock\Speed::of($now ?? $realNow, $clockSpeed); + + if (\is_null($now)) { + $move = static fn(PointInTime $now): PointInTime => $now; + } else if ($now->aheadOf($realNow)) { + $delta = $now->elapsedSince($realNow)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goForward($delta); + } else { + $delta = $realNow->elapsedSince($now)->asPeriod(); + $move = static fn(PointInTime $now): PointInTime => $now->goBack($delta); + } + + return new self( + $clock, + $move, + null, + $speed, + ); + } + + #[\NoDiscard] + public function now(): PointInTime + { + $now = ($this->delta)($this->clock->now()); + + if ($this->advance) { + $now = $now->goForward($this->advance); + } + + // Not sure this is correct or be applied before applying `$this->advance` + // as this means that when a process wait for 1 second it will advance + // by 2. This could lead to unexpected behaviour such as when waiting + // for timeouts. + return ($this->speed)($now); + } + + #[\NoDiscard] + public function advance(Period $period): SideEffect + { + $this->advance = $this->advance?->add($period) ?? $period; + + return SideEffect::identity; + } +} diff --git a/src/Simulation/NTPServer/Clock/Speed.php b/src/Simulation/NTPServer/Clock/Speed.php new file mode 100644 index 0000000..3dbbac8 --- /dev/null +++ b/src/Simulation/NTPServer/Clock/Speed.php @@ -0,0 +1,52 @@ + $multiplier + */ + private function __construct( + private PointInTime $previous, + private ?int $multiplier, + ) { + } + + #[\NoDiscard] + public function __invoke(PointInTime $now): PointInTime + { + if (\is_null($this->multiplier)) { + return $now; + } + + $elapsed = $now->elapsedSince($this->previous)->asPeriod(); + $this->previous = $now; + + for ($i = 1; $i < $this->multiplier; $i++) { + $now = $now->goForward($elapsed); + } + + return $now; + } + + /** + * @psalm-pure + * @internal + * + * @param ?int<2, 10> $multiplier + */ + #[\NoDiscard] + public static function of(PointInTime $previous, ?int $multiplier): self + { + return new self($previous, $multiplier); + } +} diff --git a/src/Simulation/Network.php b/src/Simulation/Network.php new file mode 100644 index 0000000..4a83913 --- /dev/null +++ b/src/Simulation/Network.php @@ -0,0 +1,104 @@ + $machines + */ + private function __construct( + private Map $machines, + private NTPServer $ntp, + private Network\Latency $latency, + ) { + } + + /** + * @internal + */ + #[\NoDiscard] + public static function new(NTPServer $ntp, Latency $latency): self + { + return new self( + Map::of(), + $ntp, + $latency->asState(), + ); + } + + /** + * @param non-empty-list $domains + */ + #[\NoDiscard] + public function with(array $domains, Machine $machine): void + { + foreach ($domains as $domain) { + $this->machines = ($this->machines)( + $domain, + $machine, + ); + } + } + + #[\NoDiscard] + public function ntp(): NTPServer + { + return $this->ntp; + } + + /** + * @return Attempt + */ + #[\NoDiscard] + public function http(Request $request): Attempt + { + ($this->latency)($this->ntp); + $host = $request->url()->authority()->host()->toString(); + + return $this + ->machines + ->get($host) + ->attempt(static fn() => new CouldNotResolveHost($host)) + ->flatMap(static fn($machine) => $machine->http($request)) + ->map(function($response) { + ($this->latency)($this->ntp); + + return $response; + }) + ->mapError(function($error) { + ($this->latency)($this->ntp); + + return $error; + }); + } + + /** + * @return Attempt + */ + #[\NoDiscard] + public function ssh(string $host): Attempt + { + return $this + ->machines + ->get($host) + ->attempt(static fn() => new CouldNotResolveHost($host)); + } +} diff --git a/src/Simulation/Network/Latency.php b/src/Simulation/Network/Latency.php new file mode 100644 index 0000000..932a887 --- /dev/null +++ b/src/Simulation/Network/Latency.php @@ -0,0 +1,48 @@ +> $latencies + */ + private function __construct( + private Ring $latencies, + ) { + } + + public function __invoke(NTPServer $ntp): void + { + $this + ->latencies + ->pull() + ->map(Period::millisecond(...)) + ->match( + $ntp->advance(...), + static fn() => null, + ); + } + + /** + * @psalm-pure + * @internal + * + * @param list> $latencies + */ + #[\NoDiscard] + public static function of(array $latencies): self + { + return new self(Ring::of(...$latencies)); + } +}