From 75d250b7f765851d65105d09fffe11a3c536442c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:09:20 +0000 Subject: [PATCH] Initial scaffold: Elsa 3.5 custom API server with sample Shout activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/Elsa.CustomServer: minimal ASP.NET Core 8 host referencing Elsa 3.5 NuGets - Activities auto-registered via AddActivitiesFrom() — drop new files under Activities/ - Sqlite persistence for workflow definitions + runtime - Multi-stage Dockerfile targeting linux/amd64, ASP.NET 8 runtime, port 8080 - Forwarded-headers configured so URLs come out https:// behind Traefik - CORS wide-open so the standalone Studio (different origin) can talk to us --- .dockerignore | 11 +++ .gitignore | 28 ++++++++ Dockerfile | 30 +++++++++ README.md | 41 ++++++++++++ src/Elsa.CustomServer/Activities/Shout.cs | 21 ++++++ .../Elsa.CustomServer.csproj | 29 ++++++++ src/Elsa.CustomServer/Program.cs | 67 +++++++++++++++++++ .../appsettings.Development.json | 8 +++ src/Elsa.CustomServer/appsettings.json | 42 ++++++++++++ 9 files changed, 277 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 src/Elsa.CustomServer/Activities/Shout.cs create mode 100644 src/Elsa.CustomServer/Elsa.CustomServer.csproj create mode 100644 src/Elsa.CustomServer/Program.cs create mode 100644 src/Elsa.CustomServer/appsettings.Development.json create mode 100644 src/Elsa.CustomServer/appsettings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34fb80b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/bin +**/obj +**/App_Data +**/*.db +**/*.db-* +.git +.gitignore +.vscode +.idea +deploy.json +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f4ffe6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +## .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +## Rider / VS Code +.idea/ +.vscode/ + +## Runtime / build output +publish/ +out/ +TestResults/ + +## macOS / Linux +.DS_Store + +## Elsa runtime state +App_Data/ +*.db +*.db-* +*.sqlite +*.sqlite-journal + +## Coolify config carries the app UUID and env values, keep it local +deploy.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca0cd02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Elsa Custom Server — Docker image +# Multi-stage build so the final image only carries the ASP.NET runtime. + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /source + +# Restore first for better layer caching. +COPY src/Elsa.CustomServer/Elsa.CustomServer.csproj src/Elsa.CustomServer/ +RUN dotnet restore src/Elsa.CustomServer/Elsa.CustomServer.csproj + +# Then copy the rest and publish. +COPY src/ src/ +RUN dotnet publish src/Elsa.CustomServer/Elsa.CustomServer.csproj \ + -c Release -o /app/publish --no-restore /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish ./ + +# Ensure the SQLite DB and any workflow blobs land on a writable path we can volume-mount. +RUN mkdir -p /app/App_Data && chmod 777 /app/App_Data +VOLUME ["/app/App_Data"] + +# Kestrel binds to whatever HTTP_PORTS says; keep the container port fixed so Coolify +# can wire Traefik consistently. +ENV ASPNETCORE_URLS=http://+:8080 +ENV HTTP_PORTS=8080 + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Elsa.CustomServer.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bbb9b3 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Elsa Custom Server + +Custom Elsa Workflows API server. Extends stock Elsa 3.x with our own activities. + +## Where activities live + +`src/Elsa.CustomServer/Activities/*.cs` — anything marked `[Activity(...)]` under this +folder is auto-registered via `AddActivitiesFrom()` in `Program.cs`. + +To add a new node type in the Studio designer: + +1. Drop a new file under `Activities/`. +2. Inherit from `CodeActivity`, `CodeActivity`, or `Activity` for triggerable ones. +3. Rebuild and redeploy — Studio picks it up automatically the next time it fetches + the activity descriptors. + +Example — see `Activities/Shout.cs`. + +## Local run + +```sh +dotnet run --project src/Elsa.CustomServer +``` + +Listens on http://localhost:8080; API root is `/elsa/api`. + +## Docker + +```sh +docker build -t elsa-custom-server . +docker run --rm -p 8080:8080 \ + -e Identity__Tokens__SigningKey="$(openssl rand -base64 64)" \ + elsa-custom-server +``` + +## Deployment + +Coolify builds this image from the Dockerfile at repo root when the app is created +with `build_pack: "dockerfile"`. The Studio (separate container running the stock +`elsaworkflows/elsa-studio-v3` image) needs its `BACKEND__URL` env var pointed at +this server's `/elsa/api`. diff --git a/src/Elsa.CustomServer/Activities/Shout.cs b/src/Elsa.CustomServer/Activities/Shout.cs new file mode 100644 index 0000000..2f57a13 --- /dev/null +++ b/src/Elsa.CustomServer/Activities/Shout.cs @@ -0,0 +1,21 @@ +using Elsa.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; + +namespace Elsa.CustomServer.Activities; + +/// Sample custom activity — appears in the Studio toolbox under the "Custom" category. +[Activity("Custom", "Shout", "Uppercases a string and returns it.", Kind = ActivityKind.Task)] +public class Shout : CodeActivity +{ + [Input(Description = "The text to shout.")] + public Input Text { get; set; } = default!; + + protected override void Execute(ActivityExecutionContext context) + { + var input = context.Get(Text) ?? string.Empty; + var result = input.ToUpperInvariant() + "!"; + context.SetResult(result); + } +} diff --git a/src/Elsa.CustomServer/Elsa.CustomServer.csproj b/src/Elsa.CustomServer/Elsa.CustomServer.csproj new file mode 100644 index 0000000..94b1947 --- /dev/null +++ b/src/Elsa.CustomServer/Elsa.CustomServer.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + Elsa.CustomServer + Elsa.CustomServer + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Elsa.CustomServer/Program.cs b/src/Elsa.CustomServer/Program.cs new file mode 100644 index 0000000..05d4b9c --- /dev/null +++ b/src/Elsa.CustomServer/Program.cs @@ -0,0 +1,67 @@ +using System.Text.Encodings.Web; +using Elsa.EntityFrameworkCore.Extensions; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.EntityFrameworkCore.Modules.Runtime; +using Elsa.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; + +var builder = WebApplication.CreateBuilder(args); +var configuration = builder.Configuration; +var identitySection = configuration.GetSection("Identity"); +var identityTokenSection = identitySection.GetSection("Tokens"); + +builder.Services.AddElsa(elsa => +{ + elsa + // Discovers everything in this assembly decorated with [Activity] — including anything you drop under Activities/. + .AddActivitiesFrom() + .UseIdentity(identity => + { + identity.TokenOptions = options => identityTokenSection.Bind(options); + identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options)); + }) + .UseDefaultAuthentication() + .UseWorkflowManagement(m => m.UseEntityFrameworkCore(ef => ef.UseSqlite())) + .UseWorkflowRuntime(r => r.UseEntityFrameworkCore(ef => ef.UseSqlite())) + .UseScheduling() + .UseWorkflowsApi() + .UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options)) + .UseCSharp() + .UseJavaScript() + .UseLiquid(liquid => liquid.FluidOptions = options => options.Encoder = HtmlEncoder.Default); +}); + +// Studio (whichever host) is on a different origin, so allow it in. +builder.Services.AddCors(cors => cors.AddDefaultPolicy(p => p + .AllowAnyHeader() + .AllowAnyMethod() + .AllowAnyOrigin() + .WithExposedHeaders("x-elsa-workflow-instance-id"))); + +builder.Services.AddHealthChecks(); + +// Traefik terminates TLS in front of us; without this, self-generated URLs come out as http:// +builder.Services.Configure(o => +{ + o.ForwardedHeaders = ForwardedHeaders.All; + o.KnownNetworks.Clear(); + o.KnownProxies.Clear(); +}); + +var app = builder.Build(); + +app.UseForwardedHeaders(); +app.UseCors(); +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseWorkflowsApi(); +app.UseWorkflows(); +app.UseJsonSerializationErrorHandler(); +app.MapHealthChecks("/health"); +app.MapGet("/", () => Results.Ok(new { name = "Elsa Custom Server", status = "ok" })); + +app.Run(); diff --git a/src/Elsa.CustomServer/appsettings.Development.json b/src/Elsa.CustomServer/appsettings.Development.json new file mode 100644 index 0000000..34f00ef --- /dev/null +++ b/src/Elsa.CustomServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/Elsa.CustomServer/appsettings.json b/src/Elsa.CustomServer/appsettings.json new file mode 100644 index 0000000..3d9735b --- /dev/null +++ b/src/Elsa.CustomServer/appsettings.json @@ -0,0 +1,42 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;" + }, + "Http": { + "BaseUrl": "https://elsa-api.tes.gd", + "BasePath": "/workflows" + }, + "Identity": { + "Tokens": { + "SigningKey": "CHANGE_ME_VIA_ENV_Identity__Tokens__SigningKey", + "AccessTokenLifetime": "1:00:00", + "RefreshTokenLifetime": "1.00:00:00" + }, + "Roles": [ + { + "Id": "admin", + "Name": "Administrator", + "Permissions": [ "*" ] + } + ], + "Users": [ + { + "Id": "admin", + "Name": "admin", + "Email": "admin@elsa.local", + "HashedPassword": "", + "HashedPasswordSalt": "", + "Roles": [ "admin" ] + } + ], + "Applications": [] + } +}