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
-[](https://github.com/innmind/testing/actions?query=workflow%3ACI)
+[](https://github.com/Innmind/testing/actions/workflows/ci.yml)
[](https://codecov.io/gh/innmind/testing)
[](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));
+ }
+}