From f24b4394cfc2d4e6fdc9e1926b3a88020efc3916 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 25 Sep 2025 20:58:21 +0200 Subject: [PATCH] Add `WorkflowStateCommitted` notification support and update state handling logic - Introduced `WorkflowStateCommitted` notification to encapsulate workflow execution context, state, and instance details. - Updated `DefaultCommitStateHandler` to publish `WorkflowStateCommitted` via `IMediator`. - Adjusted `DispatchWorkflowExtensions` to use `WorkflowStateCommitted` for workflow completion. Updates KubernetesClient and Microsoft packages (#6917) * Remove Proto.Cluster.Kubernetes dependency due to vulnerability - Temporarily removed `Proto.Cluster.Kubernetes` package and provider integration because of a vulnerability in its dependency (https://avd.aquasec.com/nvd/2025/cve-2025-9708). - Adjusted related cluster provider and remote configuration logic. - Updated `PortAttribute` default parameter for clarity. * Revert "Remove Proto.Cluster.Kubernetes dependency due to vulnerability" This reverts commit 0720d970968e4f7338825407258b34ddffb1d2a4. * Add KubernetesClient package and update MicrosoftVersion to 9.0.9 - Added `KubernetesClient` package to the project dependencies. - Updated `MicrosoftVersion` to `9.0.9` in `Directory.Packages.props`. Update Polly packages - Bump Polly and Polly.Extensions package versions to 8.6.3. Update `Microsoft.AspNetCore.Authorization` to use `MicrosoftVersion` property Ensure Docker images ship CA trust and add TLS smoke tests (#6918) Remove TlsSmoke project and related solution references - Deleted `TlsSmoke` project files (`Program.cs` and `TlsSmoke.csproj`). - Removed `TlsSmoke` project reference from the solution file (`Elsa.sln`). Add comprehensive Copilot coding agent instructions for repository onboarding (#6920) * Initial plan * Add comprehensive .github/copilot-instructions.md with validated build instructions Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> Add ForEach tests, introduce asynchronous workflow runner and enhance workflow events. (#6926) * Introduce asynchronous workflow runner and enhance workflow events. - Added `AsyncWorkflowRunner` to enable asynchronous workflow execution and result tracking. - Introduced new event arguments, such as `ActivityExecutedEventArgs` and `WorkflowStateCommittedEventArgs`. - Expanded `WorkflowEvents` class to include `ActivityExecuted`, `ActivityExecutedLogUpdated`, and `WorkflowStateCommitted` events. - Refactored event arguments into the `Elsa.Testing.Shared.EventArgs` namespace. - Enhanced tests with `AsyncWorkflowRunner` and new event-driven workflow scenarios. * Refactor event argument classes to unify namespace and simplify inheritance * Add shared component DotSettings file to support namespace exclusions Refactor `WaitAsync` call in `DispatchWorkflowsTests` to remove unnecessary generic type. --- .github/copilot-instructions.md | 230 ++++++++++++++++++ .github/workflows/docker-ca.yml | 72 ++++++ Directory.Packages.props | 2 +- Elsa.sln | 5 +- README.md | 26 ++ docker/ElsaServer-Datadog.Dockerfile | 48 ++-- docker/ElsaServer.Dockerfile | 23 +- docker/ElsaServerAndStudio.Dockerfile | 23 +- docker/ElsaStudio.Dockerfile | 12 +- docker/entrypoint.sh | 103 ++++++++ scripts/test-ca-trust.sh | 95 ++++++++ ...esting.Shared.Component.csproj.DotSettings | 2 + .../EventArgs/ActivityExecutedEventArgs.cs | 8 + .../ActivityExecutedLogUpdatedEventArgs.cs | 10 + .../WorkflowStateCommittedEventArgs.cs | 12 + .../Handlers/WorkflowEventHandlers.cs | 28 ++- .../Services/WorkflowEvents.cs | 6 + .../DispatchWorkflowExtensions.cs | 14 +- .../Activities/LoggingMiddleware.cs | 2 +- .../Notifications/WorkflowStateCommitted.cs | 7 + .../Services/DefaultCommitStateHandler.cs | 6 +- .../Helpers/Fixtures/WorkflowServer.cs | 8 +- .../Models/TestWorkflowExecutionResult.cs | 10 + .../Helpers/Services/AsyncWorkflowRunner.cs | 85 +++++++ .../ForEach/ForEachWorkflowTests.cs | 29 +++ .../ForEach/Workflows/ForEachWorkflow.cs | 31 +++ .../DispatchWorkflowsTests.cs | 4 +- .../ForEachTests.cs | 29 +++ 28 files changed, 873 insertions(+), 57 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/docker-ca.yml create mode 100755 docker/entrypoint.sh create mode 100755 scripts/test-ca-trust.sh create mode 100644 src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings create mode 100644 src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs create mode 100644 src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs create mode 100644 src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs create mode 100644 src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs create mode 100644 test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..cb262164f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,230 @@ +# Copilot Coding Agent Instructions for Elsa Workflows + +## Repository Overview + +**Elsa Workflows** is a powerful .NET workflow library that enables workflow execution within any .NET application. This is version 3.0, supporting .NET 9.0 and providing both a visual designer and programmatic workflow definition capabilities. + +### Key Statistics +- **Language**: C# (.NET 9.0) +- **Architecture**: Modular library with 104+ projects +- **Code Size**: ~3,500 C# files across modules +- **License**: MIT +- **Build System**: NUKE build automation +- **Target Frameworks**: .NET 9.0 (primary) + +## High-Level Architecture + +### Directory Structure +``` +src/ +├── apps/ # Reference applications (5 projects) +│ ├── Elsa.Server.Web # Workflow server only +│ ├── Elsa.ServerAndStudio.Web # Combined server + studio +│ ├── Elsa.Studio.Web # Studio web interface +│ ├── ElsaStudioWebAssembly # Studio WebAssembly app +│ └── Elsa.Server.LoadBalancer # Load balancer +├── common/ # Shared libraries (8 projects) +├── modules/ # Core functionality modules (70+ projects) +│ ├── Elsa.Workflows.Core # Core workflow engine +│ ├── Elsa.Workflows.Runtime # Runtime execution +│ ├── Elsa.Workflows.Api # REST API +│ ├── Elsa.Http # HTTP activities +│ ├── Elsa.Email # Email activities +│ └── [many others] # Database, messaging, etc. +└── clients/ # API clients +test/ +├── unit/ # Unit tests +├── integration/ # Integration tests +├── component/ # Component tests +└── performance/ # Performance tests +build/ # NUKE build configuration +docker/ # Docker configurations +``` + +### Core Components +- **Elsa.Workflows.Core**: Main workflow engine and activities +- **Elsa.Workflows.Runtime**: Workflow execution runtime +- **Elsa.Workflows.Api**: RESTful API for workflow management +- **Elsa.Workflows.Management**: Workflow definition management +- **Elsa modules**: Specialized functionality (HTTP, email, scheduling, etc.) + +## Build Instructions + +### Prerequisites +- **.NET 9.0 SDK** (verified working version: 9.0.305) +- **Build time**: Initial restore ~1-2 minutes, full compile ~5-10 minutes + +### Critical Build Information + +⚠️ **IMPORTANT**: The repository has external dependencies that may cause build failures: + +1. **External NuGet Feeds**: Some projects depend on packages from: + - `https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json` (Elsa Studio packages) + - `https://f.feedz.io/sfmskywalker/webhooks-core/nuget/index.json` (Webhooks packages) + +2. **Build Failure Workarounds**: + - Studio apps (`Elsa.Studio.Web`, `ElsaStudioWebAssembly`, `Elsa.ServerAndStudio.Web`) depend on prebuilt studio packages that may not be accessible + - Server app (`Elsa.Server.Web`) depends on WebhooksCore package that may not be accessible + - Core workflow functionality can be built independently + - Some test projects may fail due to missing external packages + +### Build Commands + +**Primary build script**: `./build.sh` (Linux/macOS) or `.\build.cmd` (Windows) + +```bash +# View available targets +./build.sh --help + +# Clean build artifacts +./build.sh Clean + +# Restore packages (may show warnings for inaccessible feeds) +./build.sh Restore --ignore-failed-sources + +# Compile core components (excludes studio apps) +./build.sh Compile + +# Run tests (limited due to external dependencies) +./build.sh Test + +# Create NuGet packages +./build.sh Pack + +# Full CI pipeline (compile, test, pack) +./build.sh Compile Test Pack +``` + +**Direct dotnet commands** for core components: +```bash +# Build specific core projects that don't require external packages +dotnet restore src/modules/Elsa.Workflows.Core/ --ignore-failed-sources +dotnet build src/modules/Elsa.Workflows.Core/ --no-restore +dotnet restore src/modules/Elsa.Workflows.Runtime/ --ignore-failed-sources +dotnet build src/modules/Elsa.Workflows.Runtime/ --no-restore + +# Note: Server apps may fail due to WebhooksCore dependency +# Build and test individual core modules +find test/unit -name "*.csproj" | head -5 | xargs -I {} dotnet build {} +``` + +### Expected Build Warnings +- `NU1900`: Unable to load service index for external feeds (safe to ignore) +- `NU1801`: Service index warnings for feedz.io sources (safe to ignore) +- `NU1101`: Missing Elsa.Studio packages (blocks studio app builds) + +### Successful Build Indicators +- Core modules (Elsa.Workflows.Core, etc.) compile successfully +- Server applications (Elsa.Server.Web) build without the studio UI +- Most modules show "succeeded with X warning(s)" (warnings are acceptable) + +## Testing + +### Test Structure +- **Unit tests**: `test/unit/` - Fast, isolated tests +- **Integration tests**: `test/integration/` - End-to-end scenarios +- **Component tests**: `test/component/` - Feature testing +- **Performance tests**: `test/performance/` - Benchmarks + +### Running Tests +```bash +# Via NUKE build system +./build.sh Test + +# Direct dotnet test (for accessible projects) +dotnet test test/unit/[specific-project]/ +dotnet test --no-build --no-restore [project-path] +``` + +**Note**: Many tests may fail to run due to external package dependencies. Focus on core workflow engine tests that don't require studio packages. + +## Development Guidelines + +### Code Standards +- **Language version**: C# latest +- **Target framework**: .NET 9.0 +- **Nullable reference types**: Enabled +- **Implicit usings**: Enabled +- **EditorConfig**: Configured (4-space indentation, CRLF line endings) + +### Architecture Patterns +- **Modular design**: Each feature area is a separate project/module +- **Dependency injection**: Heavy use of Microsoft.Extensions.DependencyInjection +- **Activity-based**: Workflows are built from composable activities +- **Async/await**: Extensive use throughout for scalability + +### Common Gotchas +1. **External Dependencies**: Studio-related projects require external packages +2. **NuGet Source Mapping**: Configured in NuGet.Config, restricts where packages can be sourced +3. **Multiple Target Frameworks**: Some projects conditionally target different frameworks +4. **Build Warnings**: Many NU1900/NU1801 warnings are expected and safe + +## Continuous Integration + +### GitHub Actions Workflow +- **Trigger**: Pull requests to `main` branch +- **Runner**: ubuntu-latest +- **.NET Version**: 9.x (latest) +- **Commands**: `./build.cmd Compile Test Pack` +- **File**: `.github/workflows/pr.yml` (auto-generated by NUKE) + +### CI Pipeline Steps +1. Checkout code +2. Setup .NET 9.x SDK +3. Execute: Compile → Test → Pack +4. Expected warnings for external feed access +5. Studio apps may be excluded from CI builds + +## Key Configuration Files + +- **Build**: `build/Build.cs` (NUKE build configuration) +- **Dependencies**: `Directory.Packages.props` (central package management) +- **Global settings**: `Directory.Build.props` +- **NuGet**: `NuGet.Config` (package sources and mapping) +- **Solution**: `Elsa.sln` (119 projects) +- **Docker**: `docker/` directory with multiple Dockerfiles +- **GitHub Actions**: `.github/workflows/` (auto-generated) + +## Docker Support + +Multiple Docker configurations available: +- `ElsaServer.Dockerfile` - Server only +- `ElsaServerAndStudio.Dockerfile` - Combined server + studio +- `ElsaStudio.Dockerfile` - Studio only +- Docker Compose configurations for development + +## Quick Start for Development + +1. **Clone and build core components**: + ```bash + git clone [repo-url] + cd elsa-core + ./build.sh Clean Restore --ignore-failed-sources + ``` + +2. **Work with core modules** (avoid studio dependencies): + ```bash + cd src/modules/Elsa.Workflows.Core + dotnet build + dotnet test ../../test/unit/[related-tests]/ + ``` + +3. **Work with core modules that don't require external dependencies**: + ```bash + cd src/modules/Elsa.Workflows.Core + dotnet restore --ignore-failed-sources + dotnet build --no-restore + ``` + +## Important Notes for Coding Agents + +1. **Always use `--ignore-failed-sources`** when restoring packages +2. **Focus on core workflow functionality** rather than studio UI components +3. **Studio apps require external packages** that may not be accessible +4. **Build warnings are normal** - don't try to fix NU1900/NU1801 warnings +5. **Test individual modules** rather than solution-wide tests when external deps fail +6. **Use direct dotnet commands** for building specific components when NUKE fails +7. **Check project references** before attempting builds - some projects have conditional references +8. **Start with core modules** like `Elsa.Workflows.Core`, `Elsa.Workflows.Runtime` which are more likely to build successfully + +Trust these instructions for build and development workflows. Only search for additional information if these instructions are incomplete or found to be incorrect. \ No newline at end of file diff --git a/.github/workflows/docker-ca.yml b/.github/workflows/docker-ca.yml new file mode 100644 index 000000000..e8a737d48 --- /dev/null +++ b/.github/workflows/docker-ca.yml @@ -0,0 +1,72 @@ +name: Docker certificate smoke tests + +on: + pull_request: + branches: + - main + paths: + - 'docker/**' + - 'scripts/test-ca-trust.sh' + - 'test/TlsSmoke/**' + - '.github/workflows/docker-ca.yml' + push: + branches: + - main + paths: + - 'docker/**' + - 'scripts/test-ca-trust.sh' + - 'test/TlsSmoke/**' + - '.github/workflows/docker-ca.yml' + +jobs: + smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: elsa-server + dockerfile: docker/ElsaServer.Dockerfile + image: elsa-server-smoke + skip_otel: '0' + - name: elsa-server-and-studio + dockerfile: docker/ElsaServerAndStudio.Dockerfile + image: elsa-server-and-studio-smoke + skip_otel: '0' + - name: elsa-studio + dockerfile: docker/ElsaStudio.Dockerfile + image: elsa-studio-smoke + skip_otel: '0' + - name: elsa-server-datadog + dockerfile: docker/ElsaServer-Datadog.Dockerfile + image: elsa-server-datadog-smoke + skip_otel: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Publish TLS smoke test app + run: dotnet publish test/TlsSmoke/TlsSmoke.csproj -c Release -o artifacts/tls-smoke + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.name }} image + run: docker build -f ${{ matrix.dockerfile }} -t ${{ matrix.image }} . + + - name: Run TLS smoke tests + env: + SKIP_OTEL: ${{ matrix.skip_otel }} + run: | + set -euo pipefail + TLS_APP_DIR="${{ github.workspace }}/artifacts/tls-smoke" + if [ "$SKIP_OTEL" = "1" ]; then + scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" -e ELSA_SKIP_OTEL_AUTO=1 + else + scripts/test-ca-trust.sh "${{ matrix.image }}" "$TLS_APP_DIR" + fi diff --git a/Directory.Packages.props b/Directory.Packages.props index 37e306f02..903d05d30 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ 3.6.0-preview.1175 - 9.0.8 + 9.0.9 9.7.0 diff --git a/Elsa.sln b/Elsa.sln index f1c26d414..3b7647720 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -59,14 +59,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{986E54 docker\ElsaStudio.Dockerfile = docker\ElsaStudio.Dockerfile docker\init-db-postgres.sh = docker\init-db-postgres.sh docker\otel-collector-config.yaml = docker\otel-collector-config.yaml + docker\docker-compose-datadog+otel-collector.yml = docker\docker-compose-datadog+otel-collector.yml + docker\entrypoint.sh = docker\entrypoint.sh EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{18453B51-25EB-4317-A4B3-B10518252E92}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{1B8D5897-902E-4632-8698-E89CAF3DDF54}" - ProjectSection(SolutionItems) = preProject - test\integration\Elsa.Logging.Core.LoggerSinkTests.cs = test\integration\Elsa.Logging.Core.LoggerSinkTests.cs - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "component", "component", "{08B41FFA-CEE3-46A7-B5C0-3EB65D37A16C}" ProjectSection(SolutionItems) = preProject diff --git a/README.md b/README.md index 09b323c49..575564ab5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,32 @@ By default, you can access http://localhost:13000 and log in with: Password: password ``` +### TLS and custom certificate authorities + +All Elsa Docker images now ship with the operating system's certificate authority bundle baked in at build time. This means you can call public HTTPS endpoints such as `https://example.com` without any additional configuration. + +If you need to trust a private or corporate CA, mount the certificate bundle into the container and reference it via `EXTRA_CA_CERT`: + +```bash +docker run \ + -v /path/to/company-ca.crt:/certs/company-ca.crt:ro \ + -e EXTRA_CA_CERT=/certs/company-ca.crt \ + elsaworkflows/elsa-server-and-studio-v3:latest +``` + +On startup, the container copies the certificate into `/usr/local/share/ca-certificates` and runs `update-ca-certificates`, making the trust available to .NET, OpenSSL, curl, and other system components. Multiple certificates can be provided by pointing `EXTRA_CA_CERT` at a directory containing `.crt` or `.pem` files. + +In highly restricted environments where you cannot modify the system trust store, you can instead rely on the standard `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables: + +```bash +docker run \ + -v /path/to/company-ca-bundle.pem:/certs/custom.pem:ro \ + -e SSL_CERT_FILE=/certs/custom.pem \ + elsaworkflows/elsa-server-and-studio-v3:latest +``` + +> ℹ️ Installing the CA bundle adds roughly 300KB to the Debian-based images. No package managers run at container startup; all trust updates happen immutably at build time or via the mounted certificates shown above. + ## Table of Contents - [Documentation](#documentation) diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index 7d5022c63..b7f1a3f4b 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -1,5 +1,5 @@ # Version: 1 -# Description: Dockerfile for building and running Elsa Server +# Description: Dockerfile for building and running Elsa Server with Datadog and OpenTelemetry auto-instrumentation FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build WORKDIR /source @@ -22,36 +22,40 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + unzip \ + wget \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so -# Install dependencies -RUN apt-get update && apt-get install -y wget unzip curl - # Set environment variables for OpenTelemetry Auto-Instrumentation -ENV OTEL_DOTNET_AUTO_HOME=/otel -ENV OTEL_LOG_LEVEL="debug" +ENV OTEL_DOTNET_AUTO_HOME=/otel \ + OTEL_LOG_LEVEL="debug" # Download and extract OpenTelemetry Auto-Instrumentation ARG OTEL_VERSION=1.7.0 -RUN mkdir /otel -RUN curl -L -o /otel/otel-dotnet-install.sh https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh -RUN chmod +x /otel/otel-dotnet-install.sh -RUN /bin/bash /otel/otel-dotnet-install.sh - -# Provide necessary permissions for the script to execute -RUN chmod +x /otel/instrument.sh +RUN mkdir -p /otel \ + && curl -L -o /otel/otel-dotnet-install.sh "https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh" \ + && chmod +x /otel/otel-dotnet-install.sh \ + && /bin/bash /otel/otel-dotnet-install.sh \ + && chmod +x /otel/instrument.sh EXPOSE 8080/tcp EXPOSE 443/tcp -# Instrument the application and start it -ENTRYPOINT ["/bin/bash", "-c", "source /otel/instrument.sh && dotnet Elsa.Server.Web.dll"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Server.Web.dll"] diff --git a/docker/ElsaServer.Dockerfile b/docker/ElsaServer.Dockerfile index b31307eb9..2695ca4de 100644 --- a/docker/ElsaServer.Dockerfile +++ b/docker/ElsaServer.Dockerfile @@ -19,17 +19,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.Server.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Server.Web.dll"] diff --git a/docker/ElsaServerAndStudio.Dockerfile b/docker/ElsaServerAndStudio.Dockerfile index 27b33e992..aaadba910 100644 --- a/docker/ElsaServerAndStudio.Dockerfile +++ b/docker/ElsaServerAndStudio.Dockerfile @@ -20,17 +20,24 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ -# Install Python 3.11 -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-dev \ - libpython3.11 \ - python3-pip && \ - rm -rf /var/lib/apt/lists/* +# Install runtime dependencies, including CA certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libpython3.11 \ + python3.11 \ + python3.11-dev \ + python3-pip \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.ServerAndStudio.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.ServerAndStudio.Web.dll"] diff --git a/docker/ElsaStudio.Dockerfile b/docker/ElsaStudio.Dockerfile index 7f2eba83c..6e76acb3d 100644 --- a/docker/ElsaStudio.Dockerfile +++ b/docker/ElsaStudio.Dockerfile @@ -20,6 +20,16 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ +# Install CA certificates so HTTPS works out of the box. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.Studio.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["dotnet", "Elsa.Studio.Web.dll"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..c3f492692 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,103 @@ +#!/bin/sh +set -e + +log() { + printf '%s\n' "$*" >&2 +} + +normalise_dest_name() { + name=$(basename "$1") + case "$name" in + *.crt|*.pem) printf '%s\n' "$name" ;; + *) printf '%s.crt\n' "$name" ;; + esac +} + +install_extra_certificates() { + cert_source="$1" + if [ ! -e "$cert_source" ]; then + log "EXTRA_CA_CERT path '$cert_source' does not exist; skipping installation." + return + fi + + target_root="/usr/local/share/ca-certificates/extra" + mkdir -p "$target_root" + rm -f "$target_root"/* 2>/dev/null || true + + copied=0 + if [ -f "$cert_source" ]; then + dest_name=$(normalise_dest_name "$cert_source") + cp "$cert_source" "$target_root/$dest_name" + copied=1 + elif [ -d "$cert_source" ]; then + for file in "$cert_source"/*.crt "$cert_source"/*.pem; do + [ -f "$file" ] || continue + dest_name=$(normalise_dest_name "$file") + cp "$file" "$target_root/$dest_name" + copied=1 + done + else + log "EXTRA_CA_CERT path '$cert_source' is neither a file nor a directory; skipping installation." + return + fi + + if [ "$copied" -eq 0 ]; then + log "No certificate files found at '$cert_source'; skipping installation." + return + fi + + if command -v update-ca-certificates >/dev/null 2>&1; then + if ! update-ca-certificates >/dev/null 2>&1; then + update-ca-certificates + fi + log "Installed custom certificate(s) from '$cert_source'." + elif command -v trust >/dev/null 2>&1; then + # Shellcheck disable because we intentionally glob. + # shellcheck disable=SC2086 + for cert in "$target_root"/*.crt; do + [ -f "$cert" ] || continue + trust anchor "$cert" + done + log "Installed custom certificate(s) using 'trust' utility." + else + log "No known certificate installation tool found; custom CA may not be applied." + fi +} + +maybe_instrument_with_otel() { + if [ "${ELSA_SKIP_OTEL_AUTO:-0}" = "1" ]; then + return + fi + + if [ -z "${OTEL_DOTNET_AUTO_HOME:-}" ]; then + return + fi + + instrument_script="${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh" + if [ ! -f "$instrument_script" ]; then + return + fi + + if ! command -v bash >/dev/null 2>&1; then + log "OpenTelemetry auto-instrumentation requested but bash is unavailable; skipping." + return + fi + + tmp_wrapper="/tmp/elsa-otel-wrapper.sh" + cat <<'WRAPPER' > "$tmp_wrapper" +#!/usr/bin/env bash +set -e +. "${OTEL_DOTNET_AUTO_HOME%/}/instrument.sh" +exec "$@" +WRAPPER + chmod +x "$tmp_wrapper" + exec "$tmp_wrapper" "$@" +} + +if [ -n "${EXTRA_CA_CERT:-}" ]; then + install_extra_certificates "$EXTRA_CA_CERT" +fi + +maybe_instrument_with_otel "$@" + +exec "$@" diff --git a/scripts/test-ca-trust.sh b/scripts/test-ca-trust.sh new file mode 100755 index 000000000..7c078606b --- /dev/null +++ b/scripts/test-ca-trust.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 [docker-run-arg ...]" >&2 + exit 1 +fi + +IMAGE="$1" +TLS_APP_DIR="$2" +shift 2 || true +DOCKER_ARGS=("$@") + +SMOKE_DLL=/tls/TlsSmoke.dll +PUBLIC_URL="https://example.com" +LOCAL_PORT=9443 + +run_smoke() { + local url="$1" + shift + docker run --rm \ + -v "${TLS_APP_DIR}:/tls:ro" \ + "${DOCKER_ARGS[@]}" \ + "$@" \ + "$IMAGE" \ + dotnet "$SMOKE_DLL" "$url" +} + +run_public_test() { + echo "[CA] Validating public trust store against ${PUBLIC_URL}" >&2 + run_smoke "$PUBLIC_URL" +} + +start_local_ca_server() { + CERT_WORKDIR=$(mktemp -d) + + openssl req -x509 -newkey rsa:2048 -days 2 -nodes -keyout "$CERT_WORKDIR/ca.key" -out "$CERT_WORKDIR/ca.crt" -subj "/CN=ElsaTestCA" >/dev/null 2>&1 + openssl req -newkey rsa:2048 -nodes -keyout "$CERT_WORKDIR/server.key" -out "$CERT_WORKDIR/server.csr" -subj "/CN=host.docker.internal" >/dev/null 2>&1 + + cat < "$CERT_WORKDIR/server.ext" +subjectAltName = DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1 +extendedKeyUsage = serverAuth +keyUsage = digitalSignature, keyEncipherment +CERTEXT + + openssl x509 -req -in "$CERT_WORKDIR/server.csr" -CA "$CERT_WORKDIR/ca.crt" -CAkey "$CERT_WORKDIR/ca.key" -CAcreateserial -out "$CERT_WORKDIR/server.crt" -days 2 -sha256 -extfile "$CERT_WORKDIR/server.ext" >/dev/null 2>&1 + + openssl s_server -quiet -accept "$LOCAL_PORT" -www -cert "$CERT_WORKDIR/server.crt" -key "$CERT_WORKDIR/server.key" >/dev/null 2>&1 & + SERVER_PID=$! + + for _ in {1..20}; do + if nc -z localhost "$LOCAL_PORT" >/dev/null 2>&1; then + break + fi + sleep 0.2 + done + + echo "$CERT_WORKDIR" +} + +run_extra_ca_test() { + local cert_dir + cert_dir=$(start_local_ca_server) + trap "kill ${SERVER_PID:-0} >/dev/null 2>&1 || true; rm -rf '$cert_dir'" EXIT + + echo "[CA] Validating EXTRA_CA_CERT flow against local CA" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e EXTRA_CA_CERT=/certs/ca.crt + + echo "[CA] Validating SSL_CERT_FILE fallback" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e SSL_CERT_FILE=/certs/ca.crt + + mkdir -p "$cert_dir/dir" + cp "$cert_dir/ca.crt" "$cert_dir/dir/custom-ca.crt" + openssl rehash "$cert_dir/dir" >/dev/null 2>&1 + + echo "[CA] Validating SSL_CERT_DIR fallback" >&2 + run_smoke "https://host.docker.internal:${LOCAL_PORT}" \ + --add-host host.docker.internal:host-gateway \ + -v "${cert_dir}:/certs:ro" \ + -e SSL_CERT_DIR=/certs/dir + + kill "${SERVER_PID:-0}" >/dev/null 2>&1 || true + wait "${SERVER_PID:-0}" 2>/dev/null || true + rm -rf "$cert_dir" + trap - EXIT +} + +run_public_test +run_extra_ca_test diff --git a/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings new file mode 100644 index 000000000..5145fa48d --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Component/Elsa.Testing.Shared.Component.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs new file mode 100644 index 000000000..d31023be6 --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedEventArgs.cs @@ -0,0 +1,8 @@ +using Elsa.Workflows; + +namespace Elsa.Testing.Shared; + +public class ActivityExecutedEventArgs(ActivityExecutionContext activityExecutionContext) : EventArgs +{ + public ActivityExecutionContext ActivityExecutionContext { get; } = activityExecutionContext; +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs new file mode 100644 index 000000000..b747c608e --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/ActivityExecutedLogUpdatedEventArgs.cs @@ -0,0 +1,10 @@ +using Elsa.Workflows; +using Elsa.Workflows.Runtime.Entities; + +namespace Elsa.Testing.Shared; + +public class ActivityExecutedLogUpdatedEventArgs(WorkflowExecutionContext workflowExecutionContext, ICollection records) : EventArgs +{ + public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext; + public ICollection Records { get; } = records; +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs new file mode 100644 index 000000000..4c1c0cf9f --- /dev/null +++ b/src/common/Elsa.Testing.Shared.Component/EventArgs/WorkflowStateCommittedEventArgs.cs @@ -0,0 +1,12 @@ +using Elsa.Workflows; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.State; + +namespace Elsa.Testing.Shared; + +public class WorkflowStateCommittedEventArgs(WorkflowExecutionContext workflowExecutionContext, WorkflowState workflowState, WorkflowInstance workflowInstance) : EventArgs +{ + public WorkflowExecutionContext WorkflowExecutionContext { get; } = workflowExecutionContext; + public WorkflowState WorkflowState { get; } = workflowState; + public WorkflowInstance WorkflowInstance { get; } = workflowInstance; +} \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs index abe71cb1d..b2aad6b04 100644 --- a/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs +++ b/src/common/Elsa.Testing.Shared.Component/Handlers/WorkflowEventHandlers.cs @@ -2,6 +2,7 @@ using Elsa.Mediator.Contracts; using Elsa.Testing.Shared.Services; using Elsa.Workflows.Management.Notifications; using Elsa.Workflows.Notifications; +using Elsa.Workflows.Runtime.Notifications; using JetBrains.Annotations; namespace Elsa.Testing.Shared.Handlers; @@ -9,17 +10,38 @@ namespace Elsa.Testing.Shared.Handlers; [UsedImplicitly] public class WorkflowEventHandlers(WorkflowEvents workflowEvents) : INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler { public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken) { - workflowEvents.OnWorkflowFinished(new WorkflowFinishedEventArgs(notification.Workflow, notification.WorkflowState)); + workflowEvents.OnWorkflowFinished(new(notification.Workflow, notification.WorkflowState)); return Task.CompletedTask; } public Task HandleAsync(WorkflowInstanceSaved notification, CancellationToken cancellationToken) { - workflowEvents.OnWorkflowInstanceSaved(new WorkflowInstanceSavedEventArgs(notification.WorkflowInstance)); + workflowEvents.OnWorkflowInstanceSaved(new(notification.WorkflowInstance)); + return Task.CompletedTask; + } + + public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken) + { + workflowEvents.OnWorkflowStateCommitted(new(notification.WorkflowExecutionContext, notification.WorkflowState, notification.WorkflowInstance)); + return Task.CompletedTask; + } + + public Task HandleAsync(ActivityExecuted notification, CancellationToken cancellationToken) + { + workflowEvents.OnActivityExecuted(new(notification.ActivityExecutionContext)); + return Task.CompletedTask; + } + + public Task HandleAsync(ActivityExecutionLogUpdated notification, CancellationToken cancellationToken) + { + workflowEvents.OnActivityExecutedLogUpdated(new(notification.WorkflowExecutionContext, notification.Records)); return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs index a90b9b931..2b31409f0 100644 --- a/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs +++ b/src/common/Elsa.Testing.Shared.Component/Services/WorkflowEvents.cs @@ -4,6 +4,12 @@ public class WorkflowEvents { public event EventHandler? WorkflowFinished; public event EventHandler? WorkflowInstanceSaved; + public event EventHandler? WorkflowStateCommitted; + public event EventHandler? ActivityExecuted; + public event EventHandler? ActivityExecutedLogUpdated; public void OnWorkflowFinished(WorkflowFinishedEventArgs args) => WorkflowFinished?.Invoke(this, args); public void OnWorkflowInstanceSaved(WorkflowInstanceSavedEventArgs args) => WorkflowInstanceSaved?.Invoke(this, args); + public void OnWorkflowStateCommitted(WorkflowStateCommittedEventArgs args) => WorkflowStateCommitted?.Invoke(this, args); + public void OnActivityExecuted(ActivityExecutedEventArgs args) => ActivityExecuted?.Invoke(this, args); + public void OnActivityExecutedLogUpdated(ActivityExecutedLogUpdatedEventArgs args) => ActivityExecutedLogUpdated?.Invoke(this, args); } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs index 14ed38c3f..578802abd 100644 --- a/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Integration/DispatchWorkflowExtensions.cs @@ -5,6 +5,7 @@ using Elsa.Workflows; using Elsa.Workflows.Notifications; using Elsa.Workflows.Runtime; using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Notifications; using Elsa.Workflows.Runtime.Requests; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -13,7 +14,7 @@ namespace Elsa.Testing.Shared; public static class DispatchWorkflowExtensions { - public static async Task DispatchWorkflowAndRunToCompletion( + public static async Task DispatchWorkflowAndRunToCompletion( this IWorkflow workflowDefinition, Action? configureServices = null, Action? configureElsa = null, @@ -21,7 +22,7 @@ public static class DispatchWorkflowExtensions TimeSpan? timeout = null) { var semaphore = new SemaphoreSlim(0, 1); - WorkflowFinished? workflowFinishedRecord = null; + WorkflowStateCommitted? workflowFinishedRecord = null; var host = Host.CreateDefaultBuilder() .ConfigureServices(services => @@ -29,8 +30,11 @@ public static class DispatchWorkflowExtensions configureServices?.Invoke(services); // This notification handler will capture the WorkflowFinished record (to be returned) and release the semaphore. - services.AddNotificationHandler(sp => new(notification => + services.AddNotificationHandler(sp => new(notification => { + if (notification.WorkflowExecutionContext.Status != WorkflowStatus.Finished) + return; + workflowFinishedRecord = notification; semaphore.Release(); })); @@ -75,9 +79,9 @@ public static class DispatchWorkflowExtensions } } - class WorkflowFinishedAction(Action action) : INotificationHandler + class WorkflowFinishedAction(Action action) : INotificationHandler { - public Task HandleAsync(WorkflowFinished notification, CancellationToken cancellationToken) + public Task HandleAsync(WorkflowStateCommitted notification, CancellationToken cancellationToken) { action(notification); return Task.CompletedTask; diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs index de3c8e0d6..d3257f06b 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/LoggingMiddleware.cs @@ -20,7 +20,7 @@ public class LoggingMiddleware : IActivityExecutionMiddleware { _next = next; _logger = logger; - _stopwatch = new Stopwatch(); + _stopwatch = new(); } /// diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs new file mode 100644 index 000000000..dcad37410 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowStateCommitted.cs @@ -0,0 +1,7 @@ +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.State; + +namespace Elsa.Workflows.Runtime.Notifications; + +public record WorkflowStateCommitted(WorkflowExecutionContext WorkflowExecutionContext, WorkflowState WorkflowState, WorkflowInstance WorkflowInstance) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs index 3df97e11c..768c9c373 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultCommitStateHandler.cs @@ -1,6 +1,8 @@ +using Elsa.Mediator.Contracts; using Elsa.Workflows.CommitStates; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Notifications; using Elsa.Workflows.Runtime.Requests; using Elsa.Workflows.State; @@ -10,6 +12,7 @@ public class DefaultCommitStateHandler( IWorkflowInstanceManager workflowInstanceManager, IBookmarksPersister bookmarkPersister, IVariablePersistenceManager variablePersistenceManager, + IMediator mediator, ILogRecordSink activityExecutionLogRecordSink, ILogRecordSink workflowExecutionLogRecordSink) : ICommitStateHandler { @@ -26,9 +29,10 @@ public class DefaultCommitStateHandler( await activityExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken); await workflowExecutionLogRecordSink.PersistExecutionLogsAsync(workflowExecutionContext, cancellationToken); await variablePersistenceManager.SaveVariablesAsync(workflowExecutionContext); - await workflowInstanceManager.SaveAsync(workflowState, cancellationToken); + var workflowInstance = await workflowInstanceManager.SaveAsync(workflowState, cancellationToken); workflowExecutionContext.ExecutionLog.Clear(); workflowExecutionContext.ClearCompletedActivityExecutionContexts(); await workflowExecutionContext.ExecuteDeferredTasksAsync(); + await mediator.SendAsync(new WorkflowStateCommitted(workflowExecutionContext, workflowState, workflowInstance), cancellationToken); } } \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 62359f7ca..40d8276eb 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -5,6 +5,8 @@ using Elsa.Identity.Providers; using Elsa.Testing.Shared.Services; using Elsa.Workflows.ComponentTests.Decorators; using Elsa.Workflows.ComponentTests.Materializers; +using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach; +using Elsa.Workflows.ComponentTests.Services; using Elsa.Workflows.ComponentTests.WorkflowProviders; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime.Distributed.Extensions; @@ -87,12 +89,14 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl { services .AddSingleton() - .AddScoped() + .AddScoped() + .AddSingleton() .AddScoped() .AddSingleton() .AddScoped() .AddNotificationHandlersFrom() - .AddWorkflowDefinitionProvider() + .AddWorkflowsProvider() + .AddNotificationHandlersFrom() .Decorate() ; }); diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs new file mode 100644 index 000000000..5db0f9ef8 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Models/TestWorkflowExecutionResult.cs @@ -0,0 +1,10 @@ +using Elsa.Workflows.Runtime.Entities; + +namespace Elsa.Workflows.ComponentTests.Models; + +/// +/// Represents the result of a test workflow execution, including the workflow execution context and activity execution records. +/// +/// The workflow execution context after completion. +/// The collection of activity execution records for the workflow. +public record TestWorkflowExecutionResult(WorkflowExecutionContext WorkflowExecutionContext, ICollection ActivityExecutionRecords); \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs new file mode 100644 index 000000000..8fb09b3a7 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Services/AsyncWorkflowRunner.cs @@ -0,0 +1,85 @@ +using Elsa.Testing.Shared; +using Elsa.Testing.Shared.Services; +using Elsa.Workflows.ComponentTests.Models; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Messages; +using System.Collections.Concurrent; + +namespace Elsa.Workflows.ComponentTests.Services; + +/// +/// Provides functionality to execute workflows asynchronously and await their completion for testing purposes. +/// Tracks activity execution records and workflow completion signals. +/// +public class AsyncWorkflowRunner : IDisposable +{ + private readonly IWorkflowRuntime _workflowRuntime; + private readonly IIdentityGenerator _identityGenerator; + private readonly SignalManager _signalManager; + private readonly WorkflowEvents _workflowEvents; + private readonly ConcurrentDictionary _activityExecutionRecords = new(); + + /// + /// Initializes a new instance of the class. + /// + public AsyncWorkflowRunner(IWorkflowRuntime workflowRuntime, IIdentityGenerator identityGenerator, SignalManager signalManager, WorkflowEvents workflowEvents) + { + _workflowRuntime = workflowRuntime; + _identityGenerator = identityGenerator; + _signalManager = signalManager; + _workflowEvents = workflowEvents; + + _workflowEvents.WorkflowStateCommitted += OnWorkflowStateCommitted; + _workflowEvents.ActivityExecutedLogUpdated += OnActivityExecutedLogUpdated; + } + + /// + /// Runs the specified workflow definition asynchronously and waits for its completion. + /// Returns the workflow execution context and activity execution records. + /// + /// The handle of the workflow definition to execute. + /// A containing the workflow execution context and activity execution records. + public async Task RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle workflowDefinitionHandle) + { + var workflowInstanceId = _identityGenerator.GenerateId(); + var workflowClient = await _workflowRuntime.CreateClientAsync(workflowInstanceId); + await workflowClient.CreateInstanceAsync(new() + { + WorkflowDefinitionHandle = workflowDefinitionHandle + }); + _activityExecutionRecords.Clear(); + await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty); + var signalName = GetSignalName(workflowInstanceId); + var workflowExecutionContext = await _signalManager.WaitAsync(signalName); + return new(workflowExecutionContext, _activityExecutionRecords.Values.ToList()); + } + + private void OnWorkflowStateCommitted(object? sender, WorkflowStateCommittedEventArgs e) + { + if (e.WorkflowExecutionContext.Status != WorkflowStatus.Finished) + return; + + var signalName = GetSignalName(e.WorkflowExecutionContext.Id); + _signalManager.Trigger(signalName, e.WorkflowExecutionContext); + } + + private void OnActivityExecutedLogUpdated(object? sender, ActivityExecutedLogUpdatedEventArgs e) + { + foreach (var record in e.Records) + _activityExecutionRecords[record.Id] = record; + } + + private static string GetSignalName(string workflowInstanceId) => $"WorkflowInstanceCompleted-{workflowInstanceId}"; + + /// + /// Unsubscribes from workflow events and releases resources. + /// + public void Dispose() + { + _workflowEvents.WorkflowStateCommitted -= OnWorkflowStateCommitted; + _workflowEvents.ActivityExecutedLogUpdated -= OnActivityExecutedLogUpdated; + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs new file mode 100644 index 000000000..3657edbb2 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/ForEachWorkflowTests.cs @@ -0,0 +1,29 @@ +using Elsa.Common.Models; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows; +using Elsa.Workflows.ComponentTests.Services; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach; + +public class ForEachWorkflowTests : AppComponentTest +{ + private readonly AsyncWorkflowRunner _workflowRunner; + + public ForEachWorkflowTests(App app) : base(app) + { + _workflowRunner = Scope.ServiceProvider.GetRequiredService(); + } + + [Fact(DisplayName = "ForEach activity executes child activity for each collection item and supports blocking activities")] + public async Task ForEachActivity_ExecutesChildActivity_ForEachCollectionItem_AndSupportsBlocking() + { + var result = await _workflowRunner.RunAndAwaitWorkflowCompletionAsync(WorkflowDefinitionHandle.ByDefinitionId(ForEachWorkflow.DefinitionId, VersionOptions.Published)); + var writeLineExecutionRecords = result.ActivityExecutionRecords.Where(x => x.ActivityId == "WriteLine1").ToList(); + + // Assert that the workflow executed the expected number of activities. + Assert.Equal(3, writeLineExecutionRecords.Count); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs new file mode 100644 index 000000000..803c23d60 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Activities/ForEach/Workflows/ForEachWorkflow.cs @@ -0,0 +1,31 @@ +using Elsa.Extensions; +using Elsa.Scheduling.Activities; +using Elsa.Workflows.Activities; + +namespace Elsa.Workflows.ComponentTests.Scenarios.Activities.ForEach.Workflows; + +public class ForEachWorkflow : WorkflowBase +{ + public static readonly string DefinitionId = Guid.NewGuid().ToString(); + protected override void Build(IWorkflowBuilder builder) + { + builder.WithDefinitionId(DefinitionId); + builder.Root = new Sequence + { + Activities = + { + new ForEach(["a", "b", "c"]) + { + Body = new Sequence + { + Activities = + { + new WriteLine(context => $"Processing item: {context.GetVariable("CurrentValue")}"), + Delay.FromMilliseconds(100) + } + } + } + } + }; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs index cfb18b6ac..39edc2ad9 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/DispatchWorkflows/DispatchWorkflowsTests.cs @@ -26,11 +26,11 @@ public class DispatchWorkflowsTests : AppComponentTest public async Task DispatchAndWaitWorkflow_ShouldWaitForChildWorkflowToComplete() { var workflowClient = await _workflowRuntime.CreateClientAsync(); - await workflowClient.CreateInstanceAsync(new CreateWorkflowInstanceRequest + await workflowClient.CreateInstanceAsync(new() { WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionId(DispatchAndWaitWorkflow.DefinitionId, VersionOptions.Published) }); await workflowClient.RunInstanceAsync(RunWorkflowInstanceRequest.Empty); - await _signalManager.WaitAsync("Completed"); + await _signalManager.WaitAsync("Completed"); } } \ No newline at end of file diff --git a/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs new file mode 100644 index 000000000..583455911 --- /dev/null +++ b/test/integration/Elsa.Activities.IntegrationTests/ForEachTests.cs @@ -0,0 +1,29 @@ +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Elsa.Workflows.Activities; +using Xunit.Abstractions; + +namespace Elsa.Activities.IntegrationTests; + +public class ForEachTests +{ + private readonly CapturingTextWriter _capturingTextWriter = new(); + private readonly IServiceProvider _serviceProvider; + + public ForEachTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build(); + } + + [Fact(DisplayName = "ForEach executes each activity for every item in the collection")] + public async Task ForEach_ExecutesEachActivity_ForEveryItem() + { + var expectedLines = new[] {"a", "b", "c"}; + var forEach = new ForEach(expectedLines) + { + Body = new WriteLine(context => context.GetVariable("CurrentValue")) + }; + await _serviceProvider.RunActivityAsync(forEach); + Assert.Equal(expectedLines, _capturingTextWriter.Lines); + } +} \ No newline at end of file