diff --git a/.github/workflows/markdown-verification.yml b/.github/workflows/markdown-verification.yml new file mode 100644 index 0000000..2dff9c6 --- /dev/null +++ b/.github/workflows/markdown-verification.yml @@ -0,0 +1,50 @@ +name: Markdown Verification + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + branches: + - "**" + paths: + - "Documentation/**" + - ".markdownlint.json" + - ".github/workflows/markdown-verification.yml" + +permissions: + contents: read + +jobs: + markdown-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Lint Markdown files + uses: DavidAnson/markdownlint-cli2-action@v18 + with: + globs: 'Documentation/**/*.md' + + link-verification: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Site-absolute links point at the aggregated documentation site, which is built from every product + # repository at once - there is nothing in this repository for them to resolve to, and the Documentation + # repository's own build verifies them. Everything else, including every relative link within this + # repository, is checked. Keep linksToSkip in sync with Documentation/verify-markdown.sh. + - name: Check links in Documentation + uses: JustinBeckwith/linkinator-action@v1 + with: + paths: 'Documentation/**/*.md' + markdown: true + recurse: true + redirects: allow + statusCodes: '{"403":"ok"}' + linksToSkip: '(^|/)(prologue|cli/getting-started)/' diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..84098c3 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json", + "default": true, + "MD013": false, + "MD033": { + "allowed_elements": ["a", "div", "img", "h3", "p", "br", "strong", "details", "summary", "b", "code"] + }, + "MD051": false, + "MD041": false, + "MD012": { + "maximum": 3 + }, + "MD024": { + "siblings_only": true + }, + "MD032": false, + "MD060": false +} diff --git a/Documentation/context/index.md b/Documentation/context/index.md index 2f4ae4f..a01ed67 100644 --- a/Documentation/context/index.md +++ b/Documentation/context/index.md @@ -33,7 +33,7 @@ cratis context list -o plain Example output: -``` +```text * dev chronicle://localhost:35000 prod chronicle://prod.example.com:35000/ ``` @@ -107,7 +107,7 @@ cratis context show -o json Example output (plain): -``` +```text Server: chronicle://localhost:35000 Event store: default Namespace: Default @@ -159,7 +159,7 @@ cratis context path Example output: -``` +```text /Users/alice/.cratis/config.json ``` diff --git a/Documentation/reference/connection.md b/Documentation/reference/connection.md index 9629830..c7e436b 100644 --- a/Documentation/reference/connection.md +++ b/Documentation/reference/connection.md @@ -6,13 +6,13 @@ This page documents how the `cratis` CLI connects to a Chronicle server: the con Chronicle connection strings use the `chronicle://` scheme: -``` +```text chronicle://:/? ``` **Examples:** -``` +```text chronicle://localhost:35000 chronicle://prod.example.com:35000/ ``` diff --git a/Documentation/reference/run.md b/Documentation/reference/run.md index c911018..8994edd 100644 --- a/Documentation/reference/run.md +++ b/Documentation/reference/run.md @@ -7,12 +7,25 @@ cratis run [PATH] ``` ```text - Starting Stage from /work/invoicing on http://localhost:9090 - Chronicle Workbench on https://localhost:35000 + Running the Screenplay files in /work/invoicing + + Stage API http://localhost:9090 + API reference http://localhost:9090/scalar/v1 + Chronicle Workbench https://localhost:35000 + HTTPS only — sign in with admin / ChangeMeNow! + + ✓ Ready — event model 'Invoicing' as event store 'gentle-zephyr' + Press Ctrl+C to stop ``` You point it at a folder of `.play` files (or run it from inside one), and it hands that folder to the Stage container, which compiles every `.play` file it finds and exposes a live API you can drive — plus the Chronicle Workbench, for looking at what the model does to the event store. +The container's own output is kept out of the way. While it boots you get a +progress line — pulling the image, starting Chronicle, compiling the Screenplay +files, registering read models — and `Ready` appears once the Stage API answers +and the model's read models are registered, so anything you send it from that +point on is actually served. + ## Prerequisites - **Docker** must be installed and the `docker` command on your `PATH`. `cratis run` shells out to `docker run`. @@ -31,6 +44,7 @@ You point it at a folder of `.play` files (or run it from inside one), and it ha | `--tag ` | The `cratis/stage` image tag to run. Default: `latest`. | | `--port ` | Host port to publish the Stage API on. Default: `9090`. | | `--workbench-port ` | Host port to publish the Chronicle Workbench on. Default: `35000`. | +| `--verbose` | Stream the container's output instead of showing startup progress. | Global options such as `-o/--output` are also accepted — see [Global Options](global-options.md). @@ -39,12 +53,13 @@ Global options such as `-o/--output` are also accepted — see [Global Options]( The command mounts the folder into the Stage container and publishes both of the container's ports to your host: ```bash -docker run --rm -p 9090:9090 -p 35000:35000 -v "$PWD":/eventmodel cratis/stage:latest +docker run --rm --name cratis-stage-a1b2c3d4 -p 9090:9090 -p 35000:35000 -v "$PWD":/eventmodel cratis/stage:latest ``` - The folder is mounted at `/eventmodel` inside the container; Stage globs `**/*.play` beneath it and compiles them. - The Stage API is published on `http://localhost:9090` (change the host side with `--port`). Its API reference is at `http://localhost:9090/scalar/v1`. - The **Chronicle Workbench** is published on `https://localhost:35000` (change the host side with `--workbench-port`), so you can inspect the session's events, observers and read models while it runs. +- The container is named `cratis-stage-`, so a running sandbox is recognizable in `docker ps` and several can run side by side on different ports. - `--rm` removes the container when it exits, so every run starts from a clean, in-memory store. The Workbench is **HTTPS only** — open `https://localhost:35000`, not `http://`. The Chronicle port multiplexes @@ -53,7 +68,42 @@ HTTP/1.1 and HTTP/2 through ALPN, which requires TLS, so a plain `http://` reque development one, so your browser warns the first time. Sign in with the Stage image's development credentials — user `admin`, password `ChangeMeNow!`. -The command streams the container's output and exits with the container's exit code. Stop the session with `Ctrl+C`. +## Output + +The container's output is hidden while it starts, and replaced by a progress +line. `Ready` is printed once the Stage API answers *and* the model's read +models have been registered with Chronicle — the API starts listening a few +seconds before that, so waiting for the registration is what makes `Ready` mean +the session is usable. + +If the container stops before it gets there, the error it reported is shown +along with the last lines it wrote, and the command exits with a server error. +Run with `--verbose` to stream the container's output as it happens instead — +useful when the captured tail is not enough to tell what went wrong. + +With `-o json` or `-o json-compact`, nothing is printed until the session is +ready, and then a single object is emitted with the resolved endpoints: + +```json +{ + "status": "ready", + "path": "/work/invoicing", + "eventModel": "Invoicing", + "eventStore": "gentle-zephyr", + "stageApi": "http://localhost:9090", + "apiReference": "http://localhost:9090/scalar/v1", + "workbench": "https://localhost:35000" +} +``` + +## Stopping + +The command keeps running until you stop it with `Ctrl+C`. It then stops the +container, waits for Docker to remove it, and exits with `0` — so the prompt +comes back only once the sandbox is actually gone. A second `Ctrl+C` terminates +the command immediately, leaving whatever Docker is doing to finish on its own. +When the session ends by itself, the command exits with a server error if the +container failed. ## Errors diff --git a/Documentation/scenarios/replay-a-projection.md b/Documentation/scenarios/replay-a-projection.md index 4c0e697..48bbd43 100644 --- a/Documentation/scenarios/replay-a-projection.md +++ b/Documentation/scenarios/replay-a-projection.md @@ -56,5 +56,5 @@ cratis chronicle observers show ## Done when The observer's sequence number matches the event log tail and its read model reflects the new shape. -Spot-check the rebuilt data with [`read-models`](/cli/chronicle/read-models/) or by +Spot-check the rebuilt data with [`read-models`](../chronicle/read-models.md) or by [reading the events](./verify-events-were-appended.md) it was built from. diff --git a/Documentation/verify-markdown.sh b/Documentation/verify-markdown.sh index 9fae3f0..5eef3b8 100755 --- a/Documentation/verify-markdown.sh +++ b/Documentation/verify-markdown.sh @@ -3,11 +3,19 @@ # Markdown Verification Script # This script runs the same markdown linting and link verification that runs in CI -set -e +# No `set -e` here: both steps have to run so the summary reports everything that failed, not just the first. +set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# Site-absolute links (`/prologue/`, `/cli/getting-started/`) point at the aggregated documentation site, which +# is built from every product repository at once - there is nothing in this repository for them to resolve to, +# and the Documentation repository's own build verifies them. Everything else, including every relative link +# within this repository, is checked. +# Keep in sync with .github/workflows/markdown-verification.yml. +LINKS_TO_SKIP='(^|/)(prologue|cli/getting-started)/' + echo "==========================================" echo "Markdown Verification" echo "==========================================" @@ -51,7 +59,7 @@ echo "" echo "This may take a few minutes to check all links..." echo "" -npx linkinator "Documentation/**/*.md" --markdown --recurse --verbosity error --status-code "403:ok" --skip "^(https?:\\/\\/)?(localhost|127\\.0\\.0\\.1)(:\\d+)?(\\/|$)" +npx linkinator "Documentation/**/*.md" --markdown --recurse --verbosity error --status-code "403:ok" --skip "$LINKS_TO_SKIP" LINK_EXIT_CODE=$? echo "" diff --git a/README.md b/README.md index aeef082..493f2ee 100644 --- a/README.md +++ b/README.md @@ -445,9 +445,12 @@ Screenplay. Heuristics build the structure; a language model configured through ```bash cratis run # every .play in this folder cratis run ./screenplays --port 9191 +cratis run --verbose # stream the container's output ``` Runs the `.play` files in a folder in a local Stage container. Needs Docker on the `PATH`. +Reports progress while the container boots and prints `Ready` once the Stage answers — the +container's own output is hidden unless it fails, or you pass `--verbose`. diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs index b344053..2e78430 100644 --- a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs +++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs @@ -7,7 +7,7 @@ public class with_a_custom_host_port : Specification { IReadOnlyList _arguments; - void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191, 35001); + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191, 35001, "cratis-stage-abc123"); [Fact] void should_publish_the_custom_host_port_to_the_api_port() => _arguments.ShouldContain("9191:9090"); [Fact] void should_publish_the_custom_workbench_host_port_to_the_workbench_port() => _arguments.ShouldContain("35001:35000"); diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs index 4898580..1033685 100644 --- a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs +++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs @@ -7,10 +7,11 @@ public class with_matching_ports : Specification { IReadOnlyList _arguments; - void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090, 35000); + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090, 35000, "cratis-stage-abc123"); [Fact] void should_run_a_container() => _arguments.ShouldContain("run"); [Fact] void should_remove_the_container_on_exit() => _arguments.ShouldContain("--rm"); + [Fact] void should_name_the_container() => _arguments.ShouldContain("cratis-stage-abc123"); [Fact] void should_publish_the_host_port_to_the_api_port() => _arguments.ShouldContain("9090:9090"); [Fact] void should_publish_the_host_port_to_the_workbench_port() => _arguments.ShouldContain("35000:35000"); [Fact] void should_mount_the_folder_at_the_model_path() => _arguments.ShouldContain("/work/space:/eventmodel"); diff --git a/Source/Cli.Specs/for_StageContainer/when_generating_a_name.cs b/Source/Cli.Specs/for_StageContainer/when_generating_a_name.cs new file mode 100644 index 0000000..026431f --- /dev/null +++ b/Source/Cli.Specs/for_StageContainer/when_generating_a_name.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageContainer; + +public class when_generating_a_name : Specification +{ + string _first; + string _second; + + void Because() + { + _first = StageContainer.GenerateName(); + _second = StageContainer.GenerateName(); + } + + [Fact] void should_recognize_the_name_as_a_stage_sandbox() => _first.StartsWith(StageContainer.NamePrefix, StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_be_unique_so_sandboxes_can_run_side_by_side() => _first.ShouldNotEqual(_second); + [Fact] void should_stay_short_enough_to_read_in_docker_ps() => _first.Length.ShouldEqual(StageContainer.NamePrefix.Length + 8); +} diff --git a/Source/Cli.Specs/for_StageEndpoints/when_resolving_for_host_ports.cs b/Source/Cli.Specs/for_StageEndpoints/when_resolving_for_host_ports.cs new file mode 100644 index 0000000..c457361 --- /dev/null +++ b/Source/Cli.Specs/for_StageEndpoints/when_resolving_for_host_ports.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageEndpoints; + +public class when_resolving_for_host_ports : Specification +{ + StageEndpoints _endpoints; + + void Because() => _endpoints = StageEndpoints.For(9191, 35001); + + [Fact] void should_serve_the_api_over_http_on_the_host_port() => _endpoints.Api.ShouldEqual("http://localhost:9191"); + [Fact] void should_point_the_api_reference_at_the_same_host_port() => _endpoints.ApiReference.ShouldEqual("http://localhost:9191/scalar/v1"); + [Fact] void should_serve_the_workbench_over_https_on_its_own_host_port() => _endpoints.Workbench.ShouldEqual("https://localhost:35001"); +} diff --git a/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_fewer_lines_are_asked_for_than_were_written.cs b/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_fewer_lines_are_asked_for_than_were_written.cs new file mode 100644 index 0000000..0319d13 --- /dev/null +++ b/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_fewer_lines_are_asked_for_than_were_written.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageOutput.when_taking_the_tail; + +public class and_fewer_lines_are_asked_for_than_were_written : Specification +{ + StageOutput _output; + IReadOnlyList _result; + + void Establish() + { + _output = new(); + for (var line = 1; line <= 5; line++) + { + _output.Append($"line {line}"); + } + } + + void Because() => _result = _output.Tail(2); + + [Fact] void should_return_the_most_recent_lines_oldest_first() => string.Join(',', _result).ShouldEqual("line 4,line 5"); +} diff --git a/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_more_lines_were_written_than_the_capacity.cs b/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_more_lines_were_written_than_the_capacity.cs new file mode 100644 index 0000000..dd1f221 --- /dev/null +++ b/Source/Cli.Specs/for_StageOutput/when_taking_the_tail/and_more_lines_were_written_than_the_capacity.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageOutput.when_taking_the_tail; + +public class and_more_lines_were_written_than_the_capacity : Specification +{ + StageOutput _output; + IReadOnlyList _result; + + void Establish() + { + _output = new(3); + for (var line = 1; line <= 5; line++) + { + _output.Append($"line {line}"); + } + } + + void Because() => _result = _output.Tail(10); + + [Fact] void should_only_keep_the_last_lines() => string.Join(',', _result).ShouldEqual("line 3,line 4,line 5"); +} diff --git a/Source/Cli.Specs/for_StageStartup/given/a_stage_startup.cs b/Source/Cli.Specs/for_StageStartup/given/a_stage_startup.cs new file mode 100644 index 0000000..bac23fa --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/given/a_stage_startup.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.given; + +public class a_stage_startup : Specification +{ + protected StageStartup _startup; + + void Establish() => _startup = new("cratis/stage:latest"); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_an_earlier_phase_is_reported_again.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_an_earlier_phase_is_reported_again.cs new file mode 100644 index 0000000..b6a1207 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_an_earlier_phase_is_reported_again.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_an_earlier_phase_is_reported_again : given.a_stage_startup +{ + bool _changed; + + void Establish() + { + _startup.Observe("Starting Chronicle (in-memory storage)..."); + _startup.Observe("Chronicle is ready."); + _startup.Observe("Starting Stage..."); + } + + void Because() => _changed = _startup.Observe("Waiting for Chronicle to be ready..."); + + [Fact] void should_stay_in_the_phase_it_reached() => _startup.Phase.ShouldEqual(StagePhase.StartingStage); + [Fact] void should_not_report_a_change() => _changed.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_chronicle_is_starting.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_chronicle_is_starting.cs new file mode 100644 index 0000000..72b59ca --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_chronicle_is_starting.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_chronicle_is_starting : given.a_stage_startup +{ + void Because() => _startup.Observe("Starting Chronicle (in-memory storage)..."); + + [Fact] void should_move_to_starting_chronicle() => _startup.Phase.ShouldEqual(StagePhase.StartingChronicle); + [Fact] void should_report_that_chronicle_is_starting() => _startup.Status.ShouldEqual("Starting Chronicle"); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_docker_is_pulling_the_image.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_docker_is_pulling_the_image.cs new file mode 100644 index 0000000..ccd4811 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_docker_is_pulling_the_image.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_docker_is_pulling_the_image : given.a_stage_startup +{ + bool _changed; + + void Because() => _changed = _startup.Observe("Unable to find image 'cratis/stage:latest' locally"); + + [Fact] void should_move_to_pulling() => _startup.Phase.ShouldEqual(StagePhase.Pulling); + [Fact] void should_report_a_change() => _changed.ShouldBeTrue(); + [Fact] void should_name_the_image_being_pulled() => _startup.Status.ShouldEqual("Pulling cratis/stage:latest"); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_it_is_the_full_startup_sequence.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_it_is_the_full_startup_sequence.cs new file mode 100644 index 0000000..c17bc45 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_it_is_the_full_startup_sequence.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +/// +/// Replays the lines the Stage container actually writes, in order, to prove the startup ends up reporting +/// the Stage as running - the condition the run command holds "Ready" back for. +/// +public class and_it_is_the_full_startup_sequence : given.a_stage_startup +{ + static readonly string[] _output = + [ + "Starting Chronicle (in-memory storage)...", + "Waiting for Chronicle to be ready...", + "info: Cratis.Chronicle.Server.Kernel[822977482]", + " Starting Cratis Chronicle Server - Version 16.11.0.0", + "Chronicle is ready.", + "Using event model from Screenplay .play files under /eventmodel", + "Starting Stage...", + " Stage API http://localhost:9090", + "info: Cratis.Stage.Host[1160324588]", + " Stage running event model 'Invoicing' as event store 'gentle-zephyr'", + "info: Microsoft.Hosting.Lifetime[14]", + " Now listening on: http://0.0.0.0:9090", + "info: Microsoft.Hosting.Lifetime[0]", + " Application started. Press Ctrl+C to shut down.", + "info: Cratis.Stage.Host[842608415]", + " Registered 6 read model(s) and their projections for event store 'gentle-zephyr'" + ]; + + readonly List _phases = []; + + void Because() + { + foreach (var line in _output) + { + if (_startup.Observe(line)) + { + _phases.Add(_startup.Phase); + } + } + } + + [Fact] void should_end_up_running() => _startup.Phase.ShouldEqual(StagePhase.Running); + [Fact] void should_report_the_event_model() => _startup.EventModel.ShouldEqual("Invoicing"); + [Fact] void should_report_the_event_store() => _startup.EventStore.ShouldEqual("gentle-zephyr"); + [Fact] void should_not_report_an_error() => _startup.Error.ShouldBeNull(); + [Fact] void should_go_through_every_phase_in_order() => _phases.ShouldContainOnly([StagePhase.StartingChronicle, StagePhase.CompilingEventModel, StagePhase.StartingStage, StagePhase.RegisteringReadModels, StagePhase.Running]); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_container_reports_an_error.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_container_reports_an_error.cs new file mode 100644 index 0000000..80441d0 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_container_reports_an_error.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_container_reports_an_error : given.a_stage_startup +{ + void Because() => _startup.Observe("ERROR: No Screenplay .play files found under /eventmodel/"); + + [Fact] void should_capture_the_error_without_the_prefix() => _startup.Error.ShouldEqual("No Screenplay .play files found under /eventmodel/"); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_line_is_not_recognized.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_line_is_not_recognized.cs new file mode 100644 index 0000000..27b7304 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_line_is_not_recognized.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_line_is_not_recognized : given.a_stage_startup +{ + bool _changed; + + void Establish() => _startup.Observe("Starting Chronicle (in-memory storage)..."); + + void Because() => _changed = _startup.Observe("warn: Microsoft.AspNetCore.Server.Kestrel[0]"); + + [Fact] void should_stay_in_the_phase_it_reached() => _startup.Phase.ShouldEqual(StagePhase.StartingChronicle); + [Fact] void should_not_report_a_change() => _changed.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_read_models_have_been_registered.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_read_models_have_been_registered.cs new file mode 100644 index 0000000..8b1b7bb --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_read_models_have_been_registered.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_read_models_have_been_registered : given.a_stage_startup +{ + void Establish() => _startup.Observe(" Application started. Press Ctrl+C to shut down."); + + void Because() => _startup.Observe(" Registered 6 read model(s) and their projections for event store 'gentle-zephyr'"); + + [Fact] void should_move_to_running() => _startup.Phase.ShouldEqual(StagePhase.Running); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_registration_failed.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_registration_failed.cs new file mode 100644 index 0000000..7d55a1d --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_registration_failed.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_registration_failed : given.a_stage_startup +{ + void Establish() => _startup.Observe(" Application started. Press Ctrl+C to shut down."); + + void Because() => _startup.Observe(" Failed to register the event model's read models and projections with Chronicle"); + + [Fact] void should_move_to_running_rather_than_wait_for_a_registration_that_will_not_come() => _startup.Phase.ShouldEqual(StagePhase.Running); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_screenplay_files_are_being_compiled.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_screenplay_files_are_being_compiled.cs new file mode 100644 index 0000000..8929fdd --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_screenplay_files_are_being_compiled.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_screenplay_files_are_being_compiled : given.a_stage_startup +{ + void Establish() => _startup.Observe("Starting Chronicle (in-memory storage)..."); + + void Because() => _startup.Observe("Chronicle is ready."); + + [Fact] void should_move_to_compiling_the_event_model() => _startup.Phase.ShouldEqual(StagePhase.CompilingEventModel); + [Fact] void should_report_that_the_screenplay_files_are_being_compiled() => _startup.Status.ShouldEqual("Compiling Screenplay files"); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_stage_reports_the_event_model_it_runs.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_stage_reports_the_event_model_it_runs.cs new file mode 100644 index 0000000..e24896d --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_the_stage_reports_the_event_model_it_runs.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_the_stage_reports_the_event_model_it_runs : given.a_stage_startup +{ + void Because() => _startup.Observe(" Stage running event model 'Invoicing' as event store 'gentle-zephyr'"); + + [Fact] void should_capture_the_event_model() => _startup.EventModel.ShouldEqual("Invoicing"); + [Fact] void should_capture_the_event_store() => _startup.EventStore.ShouldEqual("gentle-zephyr"); + [Fact] void should_move_to_starting_the_stage() => _startup.Phase.ShouldEqual(StagePhase.StartingStage); +} diff --git a/Source/Cli.Specs/for_StageStartup/when_observing_output/and_there_are_no_read_models_to_register.cs b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_there_are_no_read_models_to_register.cs new file mode 100644 index 0000000..ad10f74 --- /dev/null +++ b/Source/Cli.Specs/for_StageStartup/when_observing_output/and_there_are_no_read_models_to_register.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_StageStartup.when_observing_output; + +public class and_there_are_no_read_models_to_register : given.a_stage_startup +{ + void Establish() => _startup.Observe(" Application started. Press Ctrl+C to shut down."); + + void Because() => _startup.Observe(" Event model 'Invoicing' has no read models with projections to register"); + + [Fact] void should_move_to_running() => _startup.Phase.ShouldEqual(StagePhase.Running); +} diff --git a/Source/Cli/Commands/Run/RunCommand.cs b/Source/Cli/Commands/Run/RunCommand.cs index 03b647c..ab4380d 100644 --- a/Source/Cli/Commands/Run/RunCommand.cs +++ b/Source/Cli/Commands/Run/RunCommand.cs @@ -9,7 +9,7 @@ namespace Cratis.Cli.Commands.Run; /// /// Runs the Screenplay (.play) files in a folder in a local Stage sandbox using Docker. /// -[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API (default port 9090) and the Chronicle Workbench (default port 35000) are published on the host.")] +[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API (default port 9090) and the Chronicle Workbench (default port 35000) are published on the host. The container's output is hidden while it starts; progress is reported until the Stage answers, and the command keeps running until stopped.")] [CliCommand("run", "Run the Screenplay (.play) files in the current folder in a local Stage sandbox")] [CliExample("run")] [CliExample("run", "./screenplays")] @@ -17,8 +17,20 @@ namespace Cratis.Cli.Commands.Run; [LlmOption("--tag", "string", "The cratis/stage image tag to run (default: latest).")] [LlmOption("--port", "int", "Host port to publish the Stage API on (default: 9090).")] [LlmOption("--workbench-port", "int", "Host port to publish the Chronicle Workbench on (default: 35000).")] +[LlmOption("--verbose", "bool", "Stream the container's output instead of showing startup progress.")] public class RunCommand : AsyncCommand { + /// + /// How long to wait for the container to go away on its own after the command is interrupted, before + /// stopping it explicitly. + /// + static readonly TimeSpan _stopGrace = TimeSpan.FromSeconds(3); + + /// + /// How long to give Docker to stop and remove the container after the command is interrupted. + /// + static readonly TimeSpan _stopTimeout = TimeSpan.FromSeconds(30); + /// protected override async Task ExecuteAsync(CommandContext context, RunSettings settings, CancellationToken cancellationToken) { @@ -37,40 +49,117 @@ protected override async Task ExecuteAsync(CommandContext context, RunSetti return ExitCodes.ValidationError; } - var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port, settings.WorkbenchPort); - var startInfo = new ProcessStartInfo { FileName = "docker" }; - foreach (var argument in arguments) + var endpoints = StageEndpoints.For(settings.Port, settings.WorkbenchPort); + RunOutput.WriteHeader(format, path, endpoints); + + using var session = Start(path, settings); + if (session is null) { - startInfo.ArgumentList.Add(argument); + OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode); + return ExitCodes.ConnectionError; } - if (!string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal) && - !string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) && - !string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal)) + // The session outlives the startup, so Ctrl+C has to shut the container down rather than terminate + // the command while the sandbox is still running. + using var interrupt = ConsoleInterrupt.LinkedTo(cancellationToken); + + try { - AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Starting Stage from {path.EscapeMarkup()} on http://localhost:{settings.Port}[/]"); - AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Chronicle Workbench on https://localhost:{settings.WorkbenchPort} (HTTPS only — plain http returns an empty response)[/]"); + if (settings.Verbose) + { + await session.WaitForExit(interrupt.Token); + return ExitCodeFor(session); + } + + if (!await WaitUntilReady(session, settings, format, interrupt.Token)) + { + // The container exited on its own - wait for the process so all of its output is captured, + // then show it, since it was hidden while starting. + await session.WaitForExit(CancellationToken.None); + RunOutput.WriteFailure(format, session); + return ExitCodes.ServerError; + } + + RunOutput.WriteReady(format, session.Startup, path, endpoints); + await session.WaitForExit(interrupt.Token); + + return ExitCodeFor(session); } + catch (OperationCanceledException) + { + // A Ctrl+C from a terminal reaches the Docker client too, which stops and removes the container on + // its own. Wait for that rather than returning while the sandbox is still being torn down. + RunOutput.WriteStopping(format); + if (!await WaitForStop(session)) + { + RunOutput.WriteStopTimedOut(format, _stopTimeout); + } + + return ExitCodes.Success; + } + } + + static StageSession? Start(string path, RunSettings settings) + { + var name = StageContainer.GenerateName(); + var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port, settings.WorkbenchPort, name); - Process? process; try { - process = Process.Start(startInfo); + return StageSession.Start(name, $"{StageContainer.Image}:{settings.Tag}", arguments, captureOutput: !settings.Verbose); } catch (Win32Exception) { - OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode); - return ExitCodes.ConnectionError; + return null; + } + } + + static async Task WaitUntilReady(StageSession session, RunSettings settings, string format, CancellationToken cancellationToken) + { + var started = Stopwatch.GetTimestamp(); + + if (!string.Equals(format, OutputFormats.Table, StringComparison.Ordinal)) + { + return await session.WaitUntilReady(settings.Port, () => { }, cancellationToken); } - if (process is null) + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(new Style(OutputFormatter.Accent)) + .StartAsync( + RunOutput.StatusText(session.Startup, TimeSpan.Zero), + async ctx => await session.WaitUntilReady( + settings.Port, + () => ctx.Status(RunOutput.StatusText(session.Startup, Stopwatch.GetElapsedTime(started))), + cancellationToken)); + } + + static async Task WaitForStop(StageSession session) + { + if (await Exits(session, _stopGrace)) { - OutputFormatter.WriteError(format, "Failed to start Docker", "Ensure Docker is installed and the 'docker' command is on your PATH", ExitCodes.ConnectionErrorCode); - return ExitCodes.ConnectionError; + return true; } - await process.WaitForExitAsync(cancellationToken); + // Nothing signalled Docker itself - which is the case whenever the command is signalled directly rather + // than from a terminal - so the sandbox is still up and has to be stopped explicitly. + await session.Stop(); - return process.ExitCode == 0 ? ExitCodes.Success : ExitCodes.ServerError; + return await Exits(session, _stopTimeout - _stopGrace); } + + static async Task Exits(StageSession session, TimeSpan within) + { + try + { + await session.WaitForExit(CancellationToken.None).WaitAsync(within, CancellationToken.None); + return true; + } + catch (TimeoutException) + { + return false; + } + } + + static int ExitCodeFor(StageSession session) => session.ExitCode == 0 ? ExitCodes.Success : ExitCodes.ServerError; } diff --git a/Source/Cli/Commands/Run/RunOutput.cs b/Source/Cli/Commands/Run/RunOutput.cs new file mode 100644 index 0000000..ef889d3 --- /dev/null +++ b/Source/Cli/Commands/Run/RunOutput.cs @@ -0,0 +1,174 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Renders what a Stage session reports — where it can be reached, when it is ready, and what went wrong. +/// +public static class RunOutput +{ + /// + /// The number of captured output lines to show when the container fails before becoming ready. + /// + public const int FailureOutputLines = 20; + + const int LabelWidth = 20; + + /// + /// Writes the endpoints the session will be reachable on, before it starts. + /// + /// The output format. + /// The folder of Screenplay files being run. + /// The endpoints the session is published on. + public static void WriteHeader(string format, string path, StageEndpoints endpoints) + { + if (IsSilent(format) || IsJson(format)) + { + return; + } + + if (string.Equals(format, OutputFormats.Plain, StringComparison.Ordinal)) + { + Console.WriteLine($"path\t{path}"); + Console.WriteLine($"stageApi\t{endpoints.Api}"); + Console.WriteLine($"apiReference\t{endpoints.ApiReference}"); + Console.WriteLine($"workbench\t{endpoints.Workbench}"); + return; + } + + var muted = OutputFormatter.Muted.ToMarkup(); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($" [{muted}]Running the Screenplay files in {path.EscapeMarkup()}[/]"); + AnsiConsole.WriteLine(); + OutputFormatter.WriteLabel("Stage API", endpoints.Api, LabelWidth); + OutputFormatter.WriteLabel("API reference", endpoints.ApiReference, LabelWidth); + OutputFormatter.WriteLabel("Chronicle Workbench", endpoints.Workbench, LabelWidth); + AnsiConsole.MarkupLine($" {new string(' ', LabelWidth)}[{muted}]HTTPS only — sign in with {StageEndpoints.WorkbenchUser} / {StageEndpoints.WorkbenchPassword.EscapeMarkup()}[/]"); + AnsiConsole.WriteLine(); + } + + /// + /// Builds the progress text shown while the container starts. + /// + /// The startup being tracked. + /// The time the container has been starting. + /// The progress text. + public static string StatusText(StageStartup startup, TimeSpan elapsed) => + $"{startup.Status.EscapeMarkup()}... [{OutputFormatter.Muted.ToMarkup()}]{elapsed.TotalSeconds:F0}s[/]"; + + /// + /// Writes that the session is ready to accept requests. + /// + /// The output format. + /// The startup the container reported. + /// The folder of Screenplay files being run. + /// The endpoints the session is published on. + public static void WriteReady(string format, StageStartup startup, string path, StageEndpoints endpoints) + { + if (IsSilent(format)) + { + return; + } + + if (IsJson(format)) + { + OutputFormatter.WriteObject(format, new + { + status = "ready", + path, + eventModel = startup.EventModel, + eventStore = startup.EventStore, + stageApi = endpoints.Api, + apiReference = endpoints.ApiReference, + workbench = endpoints.Workbench + }); + return; + } + + if (string.Equals(format, OutputFormats.Plain, StringComparison.Ordinal)) + { + Console.WriteLine($"eventModel\t{startup.EventModel}"); + Console.WriteLine($"eventStore\t{startup.EventStore}"); + Console.WriteLine("status\tready"); + return; + } + + var muted = OutputFormatter.Muted.ToMarkup(); + AnsiConsole.MarkupLine($" [{OutputFormatter.Success.ToMarkup()}]✓ Ready[/][{muted}]{RunningModel(startup)}[/]"); + AnsiConsole.MarkupLine($" [{muted}]Press Ctrl+C to stop[/]"); + AnsiConsole.WriteLine(); + } + + /// + /// Writes that the session is being stopped. + /// + /// The output format. + public static void WriteStopping(string format) + { + if (IsSilent(format) || IsJson(format) || string.Equals(format, OutputFormats.Plain, StringComparison.Ordinal)) + { + return; + } + + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Stopping the Stage...[/]"); + } + + /// + /// Writes that the container is still stopping after being given time to shut down. + /// + /// The output format. + /// The time the container was given to stop. + public static void WriteStopTimedOut(string format, TimeSpan timeout) + { + if (IsSilent(format) || IsJson(format) || string.Equals(format, OutputFormats.Plain, StringComparison.Ordinal)) + { + return; + } + + AnsiConsole.MarkupLine($" [{OutputFormatter.Warning.ToMarkup()}]The container did not stop within {timeout.TotalSeconds:F0} seconds — check 'docker ps'[/]"); + } + + /// + /// Writes that the container stopped before it became ready, along with the output it produced. + /// + /// The output format. + /// The session that failed. + public static void WriteFailure(string format, StageSession session) + { + var error = session.Startup.Error ?? "The Stage container stopped before it was ready"; + OutputFormatter.WriteError(format, error, "Run again with --verbose to see the container's full output", ExitCodes.ServerErrorCode); + + if (IsSilent(format) || IsJson(format)) + { + return; + } + + var lines = session.Output.Tail(FailureOutputLines); + if (lines.Count == 0) + { + return; + } + + var muted = OutputFormatter.Muted.ToMarkup(); + foreach (var line in lines) + { + AnsiConsole.MarkupLine($" [{muted}]{line.EscapeMarkup()}[/]"); + } + + AnsiConsole.WriteLine(); + } + + static string RunningModel(StageStartup startup) => + startup is { EventModel.Length: > 0, EventStore.Length: > 0 } + ? $" — event model '{startup.EventModel.EscapeMarkup()}' as event store '{startup.EventStore.EscapeMarkup()}'" + : string.Empty; + + static bool IsSilent(string format) => + string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonQuiet, StringComparison.Ordinal); + + static bool IsJson(string format) => + string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal); +} diff --git a/Source/Cli/Commands/Run/RunSettings.cs b/Source/Cli/Commands/Run/RunSettings.cs index 8321676..d9a71f9 100644 --- a/Source/Cli/Commands/Run/RunSettings.cs +++ b/Source/Cli/Commands/Run/RunSettings.cs @@ -38,4 +38,12 @@ public class RunSettings : GlobalSettings [Description("Host port to publish the Chronicle Workbench on.")] [DefaultValue(35000)] public int WorkbenchPort { get; set; } = 35000; + + /// + /// Gets or sets a value indicating whether the container's own output should be streamed. + /// + [CommandOption("--verbose")] + [Description("Stream the container's output instead of showing startup progress.")] + [DefaultValue(false)] + public bool Verbose { get; set; } } diff --git a/Source/Cli/Commands/Run/StageContainer.cs b/Source/Cli/Commands/Run/StageContainer.cs index f75c1b7..b396d92 100644 --- a/Source/Cli/Commands/Run/StageContainer.cs +++ b/Source/Cli/Commands/Run/StageContainer.cs @@ -28,6 +28,18 @@ public static class StageContainer /// public const string MountPath = "/eventmodel"; + /// + /// The prefix of the name the container is given, so a running sandbox is recognizable in docker ps + /// and can be stopped by name. + /// + public const string NamePrefix = "cratis-stage-"; + + /// + /// Generates a unique name for a container, so several sandboxes can run side by side. + /// + /// The container name. + public static string GenerateName() => $"{NamePrefix}{Guid.NewGuid():N}"[..(NamePrefix.Length + 8)]; + /// /// Builds the argument list for docker run that launches the Stage container with the given /// folder mounted and the Stage API and Chronicle Workbench published on the host. @@ -36,11 +48,14 @@ public static class StageContainer /// The image tag to run. /// The host port to publish the Stage API on. /// The host port to publish the Chronicle Workbench on. + /// The name to give the container. /// The ordered argument list to pass to the docker executable. - public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort, int workbenchHostPort) => + public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort, int workbenchHostPort, string name) => [ "run", "--rm", + "--name", + name, "-p", $"{hostPort}:{ApiPort}", "-p", @@ -49,4 +64,11 @@ public static IReadOnlyList BuildRunArguments(string path, string tag, i $"{path}:{MountPath}", $"{Image}:{tag}" ]; + + /// + /// Builds the argument list for stopping a running container by name. + /// + /// The name of the container to stop. + /// The ordered argument list to pass to the docker executable. + public static IReadOnlyList BuildStopArguments(string name) => ["stop", name]; } diff --git a/Source/Cli/Commands/Run/StageEndpoints.cs b/Source/Cli/Commands/Run/StageEndpoints.cs new file mode 100644 index 0000000..9e47849 --- /dev/null +++ b/Source/Cli/Commands/Run/StageEndpoints.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// The URLs a Stage session is reachable on from the host. +/// +/// The Stage API. +/// The interactive API reference for the compiled event model. +/// The Chronicle Workbench. +public record StageEndpoints(string Api, string ApiReference, string Workbench) +{ + /// + /// The user to sign in to the Chronicle Workbench with — the Stage image ships with development credentials. + /// + public const string WorkbenchUser = "admin"; + + /// + /// The password to sign in to the Chronicle Workbench with. + /// + public const string WorkbenchPassword = "ChangeMeNow!"; + + /// + /// Resolves the endpoints for the host ports the container's ports are published on. + /// + /// The host port the Stage API is published on. + /// The host port the Chronicle Workbench is published on. + /// The resolved . + public static StageEndpoints For(int port, int workbenchPort) => + new( + $"http://localhost:{port}", + $"http://localhost:{port}/scalar/v1", + $"https://localhost:{workbenchPort}"); +} diff --git a/Source/Cli/Commands/Run/StageOutput.cs b/Source/Cli/Commands/Run/StageOutput.cs new file mode 100644 index 0000000..9bc8cff --- /dev/null +++ b/Source/Cli/Commands/Run/StageOutput.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Keeps the most recent lines the Stage container wrote. The output is hidden while the container starts, +/// so this is what is left to show the user when it fails before becoming ready. +/// +/// The number of lines to keep. +public class StageOutput(int capacity = 200) +{ + readonly Queue _lines = new(); + readonly Lock _lock = new(); + + /// + /// Appends a line, discarding the oldest one when the capacity is reached. + /// + /// The line the container wrote. + public void Append(string line) + { + lock (_lock) + { + _lines.Enqueue(line); + while (_lines.Count > capacity) + { + _lines.Dequeue(); + } + } + } + + /// + /// Gets the last lines the container wrote, oldest first. + /// + /// The maximum number of lines to return. + /// The lines, oldest first. + public IReadOnlyList Tail(int count) + { + lock (_lock) + { + return [.. _lines.Skip(Math.Max(0, _lines.Count - count))]; + } + } +} diff --git a/Source/Cli/Commands/Run/StagePhase.cs b/Source/Cli/Commands/Run/StagePhase.cs new file mode 100644 index 0000000..78a9628 --- /dev/null +++ b/Source/Cli/Commands/Run/StagePhase.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Represents how far the Stage container has come from being launched to being able to accept requests. +/// The phases only ever move forward — output matching an earlier phase never moves the startup back. +/// +public enum StagePhase +{ + /// + /// Docker has been asked to run the container, which has not reported anything yet. + /// + Starting = 0, + + /// + /// The Stage image is not present locally and Docker is pulling it. + /// + Pulling = 1, + + /// + /// The Chronicle kernel bundled in the container is starting. + /// + StartingChronicle = 2, + + /// + /// The Screenplay files in the mounted folder are being compiled into an event model. + /// + CompilingEventModel = 3, + + /// + /// The Stage host is starting and exposing the compiled event model as an API. + /// + StartingStage = 4, + + /// + /// The Stage host has started and is registering the model's read models and projections with Chronicle. + /// + RegisteringReadModels = 5, + + /// + /// Everything the container starts has reported in. + /// + Running = 6 +} diff --git a/Source/Cli/Commands/Run/StageReadiness.cs b/Source/Cli/Commands/Run/StageReadiness.cs new file mode 100644 index 0000000..6a7e7d1 --- /dev/null +++ b/Source/Cli/Commands/Run/StageReadiness.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Run; + +/// +/// Probes the Stage API to establish when the container can actually accept requests. +/// +public static class StageReadiness +{ + /// + /// The time to wait between probes. + /// + public static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250); + + /// + /// How long a single probe is given to answer. + /// + public static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2); + + /// + /// How long to keep waiting for the container to report its read models after the API starts answering, + /// before considering it ready regardless. + /// + public static readonly TimeSpan ReadModelRegistrationGrace = TimeSpan.FromSeconds(15); + + /// + /// Determines whether the Stage API answers on the given host port. Any HTTP response counts — the Stage + /// only serves the routes its event model defines, so the root answers 404 even when it is fully up. + /// + /// The to probe with. + /// The host port the Stage API is published on. + /// A for cancelling the probe. + /// True when the API answered; otherwise false. + public static async Task IsServing(HttpClient client, int port, CancellationToken cancellationToken) + { + try + { + using var response = await client.GetAsync(new Uri($"http://localhost:{port}/"), HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return true; + } + catch (HttpRequestException) + { + // Nothing is listening on the container side of the published port yet. + return false; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The probe itself timed out - the container is accepting connections but not answering yet. + return false; + } + } +} diff --git a/Source/Cli/Commands/Run/StageSession.cs b/Source/Cli/Commands/Run/StageSession.cs new file mode 100644 index 0000000..a900144 --- /dev/null +++ b/Source/Cli/Commands/Run/StageSession.cs @@ -0,0 +1,173 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics; + +namespace Cratis.Cli.Commands.Run; + +/// +/// A running Stage container — owns the Docker process, keeps what it writes out of the way and tracks its startup. +/// +public sealed class StageSession : IDisposable +{ + readonly Process _process; + readonly string _name; + + StageSession(Process process, string name, StageStartup startup, StageOutput output) + { + _process = process; + _name = name; + Startup = startup; + Output = output; + } + + /// + /// Gets the startup the container's output is interpreted into. + /// + public StageStartup Startup { get; } + + /// + /// Gets the captured output of the container. Empty when the container's output is streamed instead of captured. + /// + public StageOutput Output { get; } + + /// + /// Gets the exit code of the container, which is only meaningful once it has exited. + /// + public int ExitCode => _process.ExitCode; + + /// + /// Starts the Stage container. + /// + /// The name the container is given, which it can later be stopped by. + /// The image reference being run, reported while Docker pulls it. + /// The arguments to invoke docker with. + /// True to capture the container's output rather than letting it stream to the console. + /// The started , or null when the process could not be started. + /// Thrown when the docker executable is not on the PATH. + public static StageSession? Start(string name, string image, IReadOnlyList arguments, bool captureOutput) + { + var startInfo = new ProcessStartInfo + { + FileName = "docker", + UseShellExecute = false, + RedirectStandardOutput = captureOutput, + RedirectStandardError = captureOutput + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + var startup = new StageStartup(image); + var output = new StageOutput(); + var process = Process.Start(startInfo); + + if (process is null) + { + return null; + } + + if (captureOutput) + { + process.OutputDataReceived += (_, args) => Capture(args.Data); + process.ErrorDataReceived += (_, args) => Capture(args.Data); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + return new(process, name, startup, output); + + void Capture(string? line) + { + if (line is null) + { + return; + } + + output.Append(line); + startup.Observe(line); + } + } + + /// + /// Waits until the Stage API answers and the container has finished registering its read models. + /// + /// The host port the Stage API is published on. + /// Called on every poll so the caller can report progress. + /// A for cancelling the wait. + /// True when the Stage became ready; false when the container exited before that. + public async Task WaitUntilReady(int port, Action onProgress, CancellationToken cancellationToken) + { + using var client = new HttpClient { Timeout = StageReadiness.ProbeTimeout }; + long? servingSince = null; + + while (!_process.HasExited) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (servingSince is null && await StageReadiness.IsServing(client, port, cancellationToken)) + { + servingSince = Stopwatch.GetTimestamp(); + } + + // The API answers a few seconds before the read models are registered with Chronicle, so hold back + // until the container reports the registration is done - falling through after a grace period so a + // change to what the container reports delays readiness rather than never resolving it. + if (servingSince is { } since && + (Startup.Phase == StagePhase.Running || Stopwatch.GetElapsedTime(since) > StageReadiness.ReadModelRegistrationGrace)) + { + return true; + } + + onProgress(); + await Task.Delay(StageReadiness.PollInterval, cancellationToken); + } + + return false; + } + + /// + /// Waits for the container to exit. + /// + /// A for cancelling the wait. + /// A task representing the asynchronous operation. + public Task WaitForExit(CancellationToken cancellationToken) => _process.WaitForExitAsync(cancellationToken); + + /// + /// Asks Docker to stop the container. Only needed when the interrupt did not reach the Docker client itself, + /// which is the case whenever the command is signalled directly rather than from a terminal. + /// + /// A task representing the asynchronous operation. + public async Task Stop() + { + var startInfo = new ProcessStartInfo + { + FileName = "docker", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + foreach (var argument in StageContainer.BuildStopArguments(_name)) + { + startInfo.ArgumentList.Add(argument); + } + + using var stop = Process.Start(startInfo); + if (stop is null) + { + return; + } + + // Drained rather than shown - by this point the container is expected to be going away anyway, and + // "no such container" is a perfectly good outcome. + await stop.StandardOutput.ReadToEndAsync(); + await stop.StandardError.ReadToEndAsync(); + await stop.WaitForExitAsync(); + } + + /// + public void Dispose() => _process.Dispose(); +} diff --git a/Source/Cli/Commands/Run/StageStartup.cs b/Source/Cli/Commands/Run/StageStartup.cs new file mode 100644 index 0000000..30cc76f --- /dev/null +++ b/Source/Cli/Commands/Run/StageStartup.cs @@ -0,0 +1,127 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.RegularExpressions; + +namespace Cratis.Cli.Commands.Run; + +/// +/// Tracks how far the Stage container has come in its startup by interpreting the lines it writes. +/// Unrecognized output leaves the phase where it is, so a change to what the container prints only costs +/// progress detail — readiness itself is established by probing the Stage API. +/// +/// The image reference being run, reported while Docker pulls it. +public partial class StageStartup(string image) +{ + /// + /// The markers the container writes, in the order the startup goes through them. Matching is by substring + /// so log prefixes and message parameters (counts, names) don't matter. + /// + static readonly (string Marker, StagePhase Phase)[] _markers = + [ + ("Unable to find image", StagePhase.Pulling), + ("Pulling from", StagePhase.Pulling), + ("Starting Chronicle", StagePhase.StartingChronicle), + ("Waiting for Chronicle", StagePhase.StartingChronicle), + ("Chronicle is ready", StagePhase.CompilingEventModel), + ("Using event model from Screenplay", StagePhase.CompilingEventModel), + ("Starting Stage...", StagePhase.StartingStage), + ("Stage running event model", StagePhase.StartingStage), + ("Application started", StagePhase.RegisteringReadModels), + ("read model(s) and their projections", StagePhase.Running), + ("has no read models with projections", StagePhase.Running), + ("Failed to register the event model", StagePhase.Running) + ]; + + /// + /// Gets the phase the container is currently in. + /// + public StagePhase Phase { get; private set; } = StagePhase.Starting; + + /// + /// Gets the name of the event model the container compiled, once it has reported it. + /// + public string? EventModel { get; private set; } + + /// + /// Gets the name of the event store the container runs the model as, once it has reported it. + /// + public string? EventStore { get; private set; } + + /// + /// Gets the error the container reported before giving up, if it reported one. + /// + public string? Error { get; private set; } + + /// + /// Gets the progress text describing what the container is currently doing. + /// + public string Status => Phase switch + { + StagePhase.Pulling => $"Pulling {image}", + StagePhase.StartingChronicle => "Starting Chronicle", + StagePhase.CompilingEventModel => "Compiling Screenplay files", + StagePhase.StartingStage => "Starting the Stage", + StagePhase.RegisteringReadModels => "Registering read models", + StagePhase.Running => "Waiting for the Stage API", + _ => "Starting the Stage container" + }; + + [GeneratedRegex("event model '(?[^']*)' as event store '(?[^']*)'", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + static partial Regex RunningRegex { get; } + + /// + /// Interprets a line the container wrote and advances the startup accordingly. + /// + /// The line the container wrote. + /// True when the line moved the startup to a new phase; otherwise false. + public bool Observe(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + CaptureError(line); + CaptureModel(line); + + var phase = Phase; + foreach (var (marker, candidate) in _markers) + { + if (candidate > phase && line.Contains(marker, StringComparison.Ordinal)) + { + phase = candidate; + } + } + + if (phase == Phase) + { + return false; + } + + Phase = phase; + + return true; + } + + void CaptureError(string line) + { + const string prefix = "ERROR:"; + if (line.StartsWith(prefix, StringComparison.Ordinal)) + { + Error = line[prefix.Length..].Trim(); + } + } + + void CaptureModel(string line) + { + var match = RunningRegex.Match(line); + if (!match.Success) + { + return; + } + + EventModel = match.Groups["model"].Value; + EventStore = match.Groups["store"].Value; + } +} diff --git a/Source/Cli/ConsoleInterrupt.cs b/Source/Cli/ConsoleInterrupt.cs new file mode 100644 index 0000000..2719c13 --- /dev/null +++ b/Source/Cli/ConsoleInterrupt.cs @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli; + +/// +/// Turns Ctrl+C into cancellation so a long-running command can shut down what it started instead of being +/// terminated mid-flight. A second Ctrl+C is left alone, so the process can always be killed the usual way. +/// +public sealed class ConsoleInterrupt : IDisposable +{ + readonly CancellationTokenSource _source; + readonly ConsoleCancelEventHandler _handler; + + ConsoleInterrupt(CancellationTokenSource source) + { + _source = source; + _handler = OnCancelKeyPress; + Console.CancelKeyPress += _handler; + } + + /// + /// Gets the token that is cancelled on the first Ctrl+C, or when the token it was linked to is cancelled. + /// + public CancellationToken Token => _source.Token; + + /// + /// Starts intercepting Ctrl+C, cancelling along with the given token. + /// + /// The to link to. + /// The , which stops intercepting when disposed. + public static ConsoleInterrupt LinkedTo(CancellationToken cancellationToken) => + new(CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + + /// + public void Dispose() + { + Console.CancelKeyPress -= _handler; + _source.Dispose(); + } + + void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs args) + { + if (_source.IsCancellationRequested) + { + // Already shutting down - let this one through so the process terminates. + return; + } + + args.Cancel = true; + _source.Cancel(); + } +}