diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d5d675..ab2f343 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,9 +5,9 @@ - - - + + + @@ -15,17 +15,17 @@ - - + + - + - + - + @@ -37,7 +37,7 @@ - + @@ -45,12 +45,12 @@ - + - + - + diff --git a/Documentation/reference/connection.md b/Documentation/reference/connection.md index 369d5ff..9629830 100644 --- a/Documentation/reference/connection.md +++ b/Documentation/reference/connection.md @@ -23,7 +23,7 @@ Chronicle serves gRPC and the OAuth/HTTP API over a single TLS port (default `35 | Option | Values | Description | |---|---|---| -| `disableTls` | `true` / `false` | Disables TLS for the connection. Only needed when connecting through a plaintext-terminating proxy — the Chronicle server itself always serves TLS. | +| `skipTlsValidation` | `true` / `false` | Whether to skip validation of the server's TLS certificate. On by default, which is what lets the CLI trust a development server's self-signed certificate; set it to `false` to require a valid certificate. | | `certificatePath` | path | Path to a client certificate file, for pinning a specific expected server certificate. | | `certificatePassword` | string | Password for the certificate file. | diff --git a/Documentation/reference/run.md b/Documentation/reference/run.md index 3c9a7ce..c279ad6 100644 --- a/Documentation/reference/run.md +++ b/Documentation/reference/run.md @@ -6,7 +6,12 @@ cratis run [PATH] ``` -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. +```text + Starting Stage from /work/invoicing on http://localhost:9090 + Chronicle Workbench on https://localhost:35000 +``` + +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. ## Prerequisites @@ -25,21 +30,26 @@ 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`. | Global options such as `-o/--output` are also accepted — see [Global Options](global-options.md). ## What it does -The command mounts the folder into the Stage container and publishes the Stage API to your host: +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 -v "$PWD":/eventmodel cratis/stage:latest +docker run --rm -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`). +- 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. - `--rm` removes the container when it exits, so every run starts from a clean, in-memory store. +The Workbench is served over HTTPS with a self-signed development certificate, so your browser will warn about it +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`. ## Errors diff --git a/Integration/Chronicle/for_Context/when_creating_context.cs b/Integration/Chronicle/for_Context/when_creating_context.cs index 48d5a23..95025c4 100644 --- a/Integration/Chronicle/for_Context/when_creating_context.cs +++ b/Integration/Chronicle/for_Context/when_creating_context.cs @@ -16,7 +16,7 @@ async Task Because() "create", "integration-test-ctx", "--server", - "chronicle://localhost:35000/?disableTls=true", + "chronicle://localhost:35000/?skipTlsValidation=true", "--output", "json"); diff --git a/Integration/Chronicle/for_Context/when_deleting_context.cs b/Integration/Chronicle/for_Context/when_deleting_context.cs index a5477e2..16216c4 100644 --- a/Integration/Chronicle/for_Context/when_deleting_context.cs +++ b/Integration/Chronicle/for_Context/when_deleting_context.cs @@ -16,7 +16,7 @@ async Task Because() "create", "integration-test-del", "--server", - "chronicle://localhost:35000/?disableTls=true"); + "chronicle://localhost:35000/?skipTlsValidation=true"); _deleteResult = await CliCommandRunner.RunAsync("context", "delete", "integration-test-del", "--output", "json"); } diff --git a/Integration/Chronicle/for_Context/when_renaming_context.cs b/Integration/Chronicle/for_Context/when_renaming_context.cs index c1c6514..1160539 100644 --- a/Integration/Chronicle/for_Context/when_renaming_context.cs +++ b/Integration/Chronicle/for_Context/when_renaming_context.cs @@ -17,7 +17,7 @@ async Task Because() "create", "integration-test-ren", "--server", - "chronicle://localhost:35000/?disableTls=true"); + "chronicle://localhost:35000/?skipTlsValidation=true"); _renameResult = await CliCommandRunner.RunAsync("context", "rename", "integration-test-ren", "integration-test-renamed", "--output", "json"); diff --git a/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs b/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs index 3b9b56b..39419cb 100644 --- a/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs +++ b/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs @@ -16,7 +16,7 @@ async Task Because() "create", "integration-test-same-name", "--server", - "chronicle://localhost:35000/?disableTls=true"); + "chronicle://localhost:35000/?skipTlsValidation=true"); _renameResult = await CliCommandRunner.RunAsync("context", "rename", "integration-test-same-name", "integration-test-same-name", "--output", "json"); diff --git a/Integration/Chronicle/for_Context/when_setting_context.cs b/Integration/Chronicle/for_Context/when_setting_context.cs index 86da9eb..b88e4e9 100644 --- a/Integration/Chronicle/for_Context/when_setting_context.cs +++ b/Integration/Chronicle/for_Context/when_setting_context.cs @@ -17,7 +17,7 @@ async Task Because() "create", "integration-test-set", "--server", - "chronicle://localhost:35000/?disableTls=true"); + "chronicle://localhost:35000/?skipTlsValidation=true"); _setResult = await CliCommandRunner.RunAsync("context", "set", "integration-test-set", "--output", "json"); diff --git a/Integration/Chronicle/for_Context/when_setting_context_value.cs b/Integration/Chronicle/for_Context/when_setting_context_value.cs index 083ac15..d17d36a 100644 --- a/Integration/Chronicle/for_Context/when_setting_context_value.cs +++ b/Integration/Chronicle/for_Context/when_setting_context_value.cs @@ -8,7 +8,7 @@ public class when_setting_context_value : Specification { CliCommandResult _result = null!; - async Task Because() => _result = await CliCommandRunner.RunAsync("context", "set-value", "server", "chronicle://localhost:35000/?disableTls=true"); + async Task Because() => _result = await CliCommandRunner.RunAsync("context", "set-value", "server", "chronicle://localhost:35000/?skipTlsValidation=true"); [Fact] void should_return_success_exit_code() => _result.ExitCode.ShouldEqual(ExitCodes.Success); 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 2cdfe2f..b344053 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,8 +7,9 @@ public class with_a_custom_host_port : Specification { IReadOnlyList _arguments; - void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191); + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191, 35001); [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"); [Fact] void should_run_the_requested_tag() => _arguments.ShouldContain("cratis/stage:1.2.0"); } 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 b1d52f7..4898580 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,11 +7,12 @@ public class with_matching_ports : Specification { IReadOnlyList _arguments; - void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090); + void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090, 35000); [Fact] void should_run_a_container() => _arguments.ShouldContain("run"); [Fact] void should_remove_the_container_on_exit() => _arguments.ShouldContain("--rm"); [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"); [Fact] void should_run_the_tagged_image() => _arguments.ShouldContain("cratis/stage:latest"); } diff --git a/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs b/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs index 516eba1..cb1e750 100644 --- a/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs +++ b/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs @@ -44,9 +44,11 @@ protected override async Task ExecuteAsync(CommandContext context, LoginSet try { var connectionString = new ChronicleConnectionString(settings.ResolveConnectionString()); - var disableTls = connectionString.DisableTls; - var scheme = disableTls ? "http" : "https"; - var tokenEndpoint = $"{scheme}://{connectionString.ServerAddress.Host}:{connectionString.ServerAddress.Port}/connect/token"; + + // Chronicle serves the OAuth endpoint over TLS on the same port as gRPC, and the client always + // connects over TLS — a connection string only relaxes certificate validation, which the handler + // below does unconditionally. + var tokenEndpoint = $"https://{connectionString.ServerAddress.Host}:{connectionString.ServerAddress.Port}/connect/token"; using var handler = CreateHandler(); using var httpClient = new HttpClient(handler); diff --git a/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs b/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs index 32eca0b..a489a7c 100644 --- a/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs +++ b/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs @@ -58,7 +58,7 @@ public static async Task Connect(ChronicleConnectionStri NullLoggerFactory.Instance, cts.Token, NullLogger.Instance, - connectionString.DisableTls, + connectionString.SkipTlsValidation, connectionString.CertificatePath, connectionString.CertificatePassword, tokenProvider, @@ -129,7 +129,7 @@ static FileSystemCachingTokenProvider CreateCachingTokenProvider(ChronicleConnec connectionString.ServerAddress, connectionString.Username ?? string.Empty, connectionString.Password ?? string.Empty, - connectionString.DisableTls, + connectionString.SkipTlsValidation, NullLogger.Instance); #pragma warning restore CA2000 return new FileSystemCachingTokenProvider(inner, cachePath); diff --git a/Source/Cli/Commands/Run/RunCommand.cs b/Source/Cli/Commands/Run/RunCommand.cs index abb2c47..0d3ba0b 100644 --- a/Source/Cli/Commands/Run/RunCommand.cs +++ b/Source/Cli/Commands/Run/RunCommand.cs @@ -9,13 +9,14 @@ 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 is published on the host (default port 9090).")] +[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.")] [CliCommand("run", "Run the Screenplay (.play) files in the current folder in a local Stage sandbox")] [CliExample("run")] [CliExample("run", "./screenplays")] [CliExample("run", "--port", "9191")] [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).")] public class RunCommand : AsyncCommand { /// @@ -36,7 +37,7 @@ protected override async Task ExecuteAsync(CommandContext context, RunSetti return ExitCodes.ValidationError; } - var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port); + var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port, settings.WorkbenchPort); var startInfo = new ProcessStartInfo { FileName = "docker" }; foreach (var argument in arguments) { @@ -48,6 +49,7 @@ protected override async Task ExecuteAsync(CommandContext context, RunSetti !string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal)) { 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}[/]"); } Process? process; diff --git a/Source/Cli/Commands/Run/RunSettings.cs b/Source/Cli/Commands/Run/RunSettings.cs index ca5a25f..8321676 100644 --- a/Source/Cli/Commands/Run/RunSettings.cs +++ b/Source/Cli/Commands/Run/RunSettings.cs @@ -30,4 +30,12 @@ public class RunSettings : GlobalSettings [Description("Host port to publish the Stage API on.")] [DefaultValue(9090)] public int Port { get; set; } = 9090; + + /// + /// Gets or sets the host port to publish the Chronicle Workbench on. + /// + [CommandOption("--workbench-port ")] + [Description("Host port to publish the Chronicle Workbench on.")] + [DefaultValue(35000)] + public int WorkbenchPort { get; set; } = 35000; } diff --git a/Source/Cli/Commands/Run/StageContainer.cs b/Source/Cli/Commands/Run/StageContainer.cs index cc97293..f75c1b7 100644 --- a/Source/Cli/Commands/Run/StageContainer.cs +++ b/Source/Cli/Commands/Run/StageContainer.cs @@ -18,6 +18,11 @@ public static class StageContainer /// public const int ApiPort = 9090; + /// + /// The port the bundled Chronicle kernel serves the Chronicle Workbench on inside the container. + /// + public const int WorkbenchPort = 35000; + /// /// The path inside the container the folder of Screenplay files is mounted at. /// @@ -25,18 +30,21 @@ public static class StageContainer /// /// Builds the argument list for docker run that launches the Stage container with the given - /// folder mounted and the Stage API published on the host. + /// folder mounted and the Stage API and Chronicle Workbench published on the host. /// /// The absolute path to the folder of Screenplay files to mount. /// The image tag to run. /// The host port to publish the Stage API on. + /// The host port to publish the Chronicle Workbench on. /// The ordered argument list to pass to the docker executable. - public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort) => + public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort, int workbenchHostPort) => [ "run", "--rm", "-p", $"{hostPort}:{ApiPort}", + "-p", + $"{workbenchHostPort}:{WorkbenchPort}", "-v", $"{path}:{MountPath}", $"{Image}:{tag}"