From a9a58350536ee73553fd3da835327878b7cd710b Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 30 Aug 2023 13:12:44 -0500 Subject: [PATCH] .NET Support (#335) Fixes #226 --- .editorconfig | 10 ++ .github/workflows/all-docker-images.yaml | 17 +++ .github/workflows/ci.yaml | 23 +++ .github/workflows/dotnet.yaml | 110 +++++++++++++ .gitignore | 1 + README.md | 5 +- cmd/prepare.go | 2 + cmd/run.go | 25 ++- cmd/run_dotnet.go | 81 ++++++++++ dockerfiles/cs.Dockerfile | 52 +++++++ .../docker-compose.for-server-image.yaml | 10 ++ dotnet.csproj | 39 +++++ .../activity/cancel_try_cancel/feature.cs | 93 +++++++++++ .../dotnet/Temporalio.Features.Harness/App.cs | 105 +++++++++++++ .../Temporalio.Features.Harness/IFeature.cs | 47 ++++++ .../PreparedFeature.cs | 25 +++ .../Temporalio.Features.Harness/Runner.cs | 104 +++++++++++++ sdkbuild/dotnet.go | 144 ++++++++++++++++++ 18 files changed, 883 insertions(+), 10 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/dotnet.yaml create mode 100644 cmd/run_dotnet.go create mode 100644 dockerfiles/cs.Dockerfile create mode 100644 dotnet.csproj create mode 100644 features/activity/cancel_try_cancel/feature.cs create mode 100644 harness/dotnet/Temporalio.Features.Harness/App.cs create mode 100644 harness/dotnet/Temporalio.Features.Harness/IFeature.cs create mode 100644 harness/dotnet/Temporalio.Features.Harness/PreparedFeature.cs create mode 100644 harness/dotnet/Temporalio.Features.Harness/Runner.cs create mode 100644 sdkbuild/dotnet.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d087b7ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[*.cs] + +##### Temporal additions ###### + +# Please keep in alphabetical order by field. + +# Some calls we mark async, like signals, that may not doing anything async +dotnet_diagnostic.CS1998.severity = none + +############################### \ No newline at end of file diff --git a/.github/workflows/all-docker-images.yaml b/.github/workflows/all-docker-images.yaml index 44f93e95..17bb7ac8 100644 --- a/.github/workflows/all-docker-images.yaml +++ b/.github/workflows/all-docker-images.yaml @@ -16,6 +16,9 @@ on: java-ver: description: Java SDK ver to build. Skipped if not specified. Must start with v. type: string + cs-ver: + description: .NET SDK ver to build. Skipped if not specified. Must start with v. + type: string do-push: description: If set, push the built images to Docker Hub. type: boolean @@ -40,6 +43,9 @@ on: java-ver: description: Java SDK ver to build. Skipped if not specified. Must start with v. type: string + cs-ver: + description: .NET SDK ver to build. Skipped if not specified. Must start with v. + type: string do-push: description: If set, push the built images to Docker Hub. type: boolean @@ -94,3 +100,14 @@ jobs: semver-latest: major do-push: ${{ inputs.do-push }} skip-cloud: ${{ inputs.skip-cloud }} + + build-dotnet-docker-images: + if: inputs.cs-ver + uses: ./.github/workflows/docker-images.yaml + secrets: inherit + with: + lang: cs + sdk-version: ${{ inputs.cs-ver }} + semver-latest: major + do-push: ${{ inputs.do-push }} + skip-cloud: ${{ inputs.skip-cloud }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1f42f888..2e9d4b43 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,20 @@ jobs: - run: ./gradlew build + build-dotnet: + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] # windows-latest - like 8x slower. Excluded for now since we're just building. + runs-on: ${{ matrix.os }} + steps: + - name: Print build information + run: 'echo head_ref: "$GITHUB_HEAD_REF", ref: "$GITHUB_REF", os: ${{ matrix.os }}' + - uses: actions/checkout@v2 + - uses: actions/setup-dotnet@v3 + - run: dotnet build + - run: dotnet test + feature-tests-ts: uses: ./.github/workflows/typescript.yaml with: @@ -112,6 +126,14 @@ jobs: features-repo-ref: ${{ github.head_ref }} features-repo-path: ${{ github.event.pull_request.head.repo.full_name }} + feature-tests-dotnet: + uses: ./.github/workflows/dotnet.yaml + with: + version: 0.1.0-beta1 + version-is-repo-ref: false + features-repo-ref: ${{ github.head_ref }} + features-repo-path: ${{ github.event.pull_request.head.repo.full_name }} + build-docker-images: uses: ./.github/workflows/all-docker-images.yaml secrets: inherit @@ -122,3 +144,4 @@ jobs: ts-ver: 'v1.5.2' java-ver: 'v1.21.1' py-ver: 'v1.0.0' + cs-ver: 'v0.1.0-beta1' diff --git a/.github/workflows/dotnet.yaml b/.github/workflows/dotnet.yaml new file mode 100644 index 00000000..ccfe8e9e --- /dev/null +++ b/.github/workflows/dotnet.yaml @@ -0,0 +1,110 @@ +name: .NET Features Testing +on: + workflow_call: + inputs: + dotnet-repo-path: + type: string + default: 'temporalio/sdk-dotnet' + version: + required: true + type: string + # When true, the version refers to a repo tag/ref. When false, NPM package version. + version-is-repo-ref: + required: true + type: boolean + features-repo-path: + type: string + default: 'temporalio/features' + features-repo-ref: + type: string + default: 'main' + # If set, download the docker image for server from the provided artifact name + docker-image-artifact-name: + type: string + required: false + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./features + steps: + - name: Print git info + run: 'echo head_ref: "$GITHUB_HEAD_REF", ref: "$GITHUB_REF", ts version: ${{ inputs.version }}' + working-directory: '.' + + - name: Download docker artifacts + if: ${{ inputs.docker-image-artifact-name }} + uses: actions/download-artifact@v3 + with: + name: ${{ inputs.docker-image-artifact-name }} + path: /tmp/server-docker + + - name: Load server Docker image + if: ${{ inputs.docker-image-artifact-name }} + run: docker load --input /tmp/server-docker/temporal-autosetup.tar + working-directory: '.' + + - name: Checkout SDK features repo + uses: actions/checkout@v3 + with: + path: features + repository: ${{ inputs.features-repo-path }} + ref: ${{ inputs.features-repo-ref }} + - name: Checkout .NET SDK repo + if: ${{ inputs.version-is-repo-ref }} + uses: actions/checkout@v2 + with: + repository: ${{ inputs.dotnet-repo-path }} + submodules: recursive + path: sdk-dotnet + ref: ${{ inputs.version }} + + - uses: actions/setup-dotnet@v3 + + - name: Install protoc + if: ${{ inputs.version-is-repo-ref }} + uses: arduino/setup-protoc@v1 + with: + version: '3.x' + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-go@v2 + with: + go-version: '^1.19' + + - uses: Swatinem/rust-cache@v1 + if: ${{ inputs.version-is-repo-ref }} + with: + working-directory: sdk-dotnet/src/Temporalio/Bridge + + # Build .NET SDK if using repo + # Don't build during install phase since we're going to explicitly build + - run: dotnet build + if: ${{ inputs.version-is-repo-ref }} + working-directory: ./sdk-dotnet + + - name: Start containerized server and dependencies + if: inputs.docker-image-artifact-name + run: | + docker-compose \ + -f /tmp/server-docker/docker-compose.yml \ + -f ./dockerfiles/docker-compose.for-server-image.yaml \ + up -d temporal-server cassandra elasticsearch + + - name: Run SDK-features tests directly + if: inputs.docker-image-artifact-name == '' + run: go run . run --lang cs ${{ inputs.docker-image-artifact-name && '--server localhost:7233 --namespace default' || ''}} --version "${{ inputs.version-is-repo-ref && '$(realpath ../sdk-dotnet)' || inputs.version }}" + + # Running the tests in their own step keeps the logs readable + - name: Run containerized SDK-features tests + if: inputs.docker-image-artifact-name + run: | + docker-compose \ + -f /tmp/server-docker/docker-compose.yml \ + -f ./dockerfiles/docker-compose.for-server-image.yaml \ + up --no-log-prefix --exit-code-from features-tests-cs features-tests-cs + + - name: Tear down docker compose + if: inputs.docker-image-artifact-name && (success() || failure()) + run: docker-compose -f /tmp/server-docker/docker-compose.yml -f ./dockerfiles/docker-compose.for-server-image.yaml down -v diff --git a/.gitignore b/.gitignore index 98106050..5749533e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ pyrightconfig.json # Build stuff bin +obj # VS Code config .vscode diff --git a/README.md b/README.md index 61b26269..56b2630d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Prerequisites: - [Python](https://www.python.org/) 3.10+ - [Poetry](https://python-poetry.org/): `poetry install` - `setuptools`: `python -m pip install -U setuptools` +- [.NET](https://dotnet.microsoft.com) 7+ Command: @@ -37,8 +38,8 @@ Command: Note, `go run .` can be used in place of `go build` + `temporal-features` to save on the build step. -`LANG` can be `go`, `java`, `ts`, or `py`. `VERSION` is per SDK and if left off, uses the latest version set for the -language in this repository. +`LANG` can be `go`, `java`, `ts`, `py`, or `cs`. `VERSION` is per SDK and if left off, uses the latest version set for +the language in this repository. `PATTERN` must match either the features relative directory _or_ the relative directory + `/feature.` via [Go path match rules](https://pkg.go.dev/path#Match) which notably does not include recursive depth matching. If diff --git a/cmd/prepare.go b/cmd/prepare.go index b74536e7..3cf70d1c 100644 --- a/cmd/prepare.go +++ b/cmd/prepare.go @@ -92,6 +92,8 @@ func (p *Preparer) Prepare(ctx context.Context) error { _, err = p.BuildTypeScriptProgram(ctx) case "py": _, err = p.BuildPythonProgram(ctx) + case "cs": + _, err = p.BuildDotNetProgram(ctx) default: err = fmt.Errorf("unrecognized language") } diff --git a/cmd/run.go b/cmd/run.go index fb029a3e..eb90f850 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -278,6 +278,13 @@ func (r *Runner) Run(ctx context.Context, patterns []string) error { if err == nil { err = r.RunPythonExternal(ctx, run) } + case "cs": + if r.config.DirName != "" { + r.program, err = sdkbuild.DotNetProgramFromDir(filepath.Join(r.rootDir, r.config.DirName)) + } + if err == nil { + err = r.RunDotNetExternal(ctx, run) + } default: err = fmt.Errorf("unrecognized language") } @@ -471,31 +478,33 @@ func (r *Runner) destroyTempDir() { } func normalizeLangName(lang string) (string, error) { + // Normalize to file extension switch lang { - case "go", "java", "ts", "py": - // Allow the full typescript or python word, but we need to match the file - // extension for the rest of run + case "go", "java", "ts", "py", "cs": case "typescript": lang = "ts" case "python": lang = "py" + case "dotnet", "csharp": + lang = "cs" default: - return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py", lang) + return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs", lang) } return lang, nil } func expandLangName(lang string) (string, error) { + // Expand to lang name switch lang { case "go", "java", "typescript", "python": - // Allow the full typescript or python word, but we need to match the file - // extension for the rest of run case "ts": lang = "typescript" case "py": lang = "python" + case "cs": + lang = "dotnet" default: - return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py", lang) + return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs", lang) } return lang, nil } @@ -503,7 +512,7 @@ func expandLangName(lang string) (string, error) { func langFlag(destination *string) *cli.StringFlag { return &cli.StringFlag{ Name: "lang", - Usage: "SDK language to run ('go' or 'java' or 'ts' or 'py')", + Usage: "SDK language to run ('go' or 'java' or 'ts' or 'py' or 'cs')", Required: true, Destination: destination, } diff --git a/cmd/run_dotnet.go b/cmd/run_dotnet.go new file mode 100644 index 00000000..05df1e40 --- /dev/null +++ b/cmd/run_dotnet.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/temporalio/features/harness/go/cmd" + "github.com/temporalio/features/sdkbuild" +) + +// BuildDotNetProgram prepares a .NET run without running it. The preparer +// config directory if present is expected to be a subdirectory name just +// beneath the root directory. +func (p *Preparer) BuildDotNetProgram(ctx context.Context) (sdkbuild.Program, error) { + p.log.Info("Building .NET project", "DirName", p.config.DirName) + + // Get version from dotnet.csproj if not present + version := p.config.Version + if version == "" { + csprojBytes, err := os.ReadFile("dotnet.csproj") + if err != nil { + return nil, fmt.Errorf("failed reading dotnet.csproj: %w", err) + } + const prefix = ` + + Exe + net7.0 + + + + + `, + }) + if err != nil { + return nil, fmt.Errorf("failed preparing: %w", err) + } + return prog, nil +} + +func (r *Runner) RunDotNetExternal(ctx context.Context, run *cmd.Run) error { + // If program not built, build it + if r.program == nil { + var err error + if r.program, err = NewPreparer(r.config.PrepareConfig).BuildDotNetProgram(ctx); err != nil { + return err + } + } + + args := []string{"--server", r.config.Server, "--namespace", r.config.Namespace} + if r.config.ClientCertPath != "" { + args = append(args, "--client-cert-path", r.config.ClientCertPath, "--client-key-path", r.config.ClientKeyPath) + } + args = append(args, run.ToArgs()...) + cmd, err := r.program.NewCommand(ctx, args...) + if err == nil { + r.log.Debug("Running Go separately", "Args", cmd.Args) + err = cmd.Run() + } + if err != nil { + return fmt.Errorf("failed running: %w", err) + } + return nil +} diff --git a/dockerfiles/cs.Dockerfile b/dockerfiles/cs.Dockerfile new file mode 100644 index 00000000..61b45df4 --- /dev/null +++ b/dockerfiles/cs.Dockerfile @@ -0,0 +1,52 @@ +# Build in a full featured container +FROM mcr.microsoft.com/dotnet/sdk:7.0-jammy as build + +# Install protobuf compiler and build tools +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install --no-install-recommends --assume-yes \ + protobuf-compiler=3.12.4* libprotobuf-dev=3.12.4* build-essential=12.* + +# Get go compiler +ARG PLATFORM=amd64 +RUN wget -q https://go.dev/dl/go1.19.1.linux-${PLATFORM}.tar.gz \ + && tar -C /usr/local -xzf go1.19.1.linux-${PLATFORM}.tar.gz + +# Install Rust for compiling the core bridge - only required for installation from a repo but is cheap enough to install +# in the "build" container (-y is for non-interactive install) +# hadolint ignore=DL4006 +RUN wget -q -O - https://sh.rustup.rs | sh -s -- -y + +ENV PATH="$PATH:/root/.cargo/bin:/usr/local/go/bin" + +WORKDIR /app + +# Copy CLI build dependencies +COPY features ./features +COPY harness ./harness +COPY sdkbuild ./sdkbuild +COPY cmd ./cmd +COPY go.mod go.sum main.go .editorconfig dotnet.csproj ./ + +# Build the CLI +RUN CGO_ENABLED=0 go build -o temporal-features + +ARG SDK_VERSION +ARG SDK_REPO_URL +ARG SDK_REPO_REF +# Could be a cloned lang SDK git repo or just an arbitrary file so the COPY command below doesn't fail. +# It was either this or turn the Dockerfile into a template, this seemed simpler although a bit awkward. +ARG REPO_DIR_OR_PLACEHOLDER +COPY ./${REPO_DIR_OR_PLACEHOLDER} ./${REPO_DIR_OR_PLACEHOLDER} + +# Prepare the feature for running +RUN CGO_ENABLED=0 ./temporal-features prepare --lang cs --dir prepared --version "$SDK_VERSION" + +# Copy the CLI and prepared feature to a distroless "run" container +FROM mcr.microsoft.com/dotnet/sdk:7.0-jammy + +COPY --from=build /app/temporal-features /app/temporal-features +COPY --from=build /app/features /app/features +COPY --from=build /app/prepared /app/prepared +# # Use entrypoint instead of command to "bake" the default command options +ENTRYPOINT ["/app/temporal-features", "run", "--lang", "cs", "--prepared-dir", "prepared"] \ No newline at end of file diff --git a/dockerfiles/docker-compose.for-server-image.yaml b/dockerfiles/docker-compose.for-server-image.yaml index 780721b1..0b40e788 100644 --- a/dockerfiles/docker-compose.for-server-image.yaml +++ b/dockerfiles/docker-compose.for-server-image.yaml @@ -54,3 +54,13 @@ services: - temporal-server networks: - temporal-dev-network + + features-tests-cs: + image: temporaliotest/features:cs + environment: + - WAIT_EXTRA_FOR_NAMESPACE + command: ["--server", "temporal-server:7233", "--namespace", "default"] + depends_on: + - temporal-server + networks: + - temporal-dev-network diff --git a/dotnet.csproj b/dotnet.csproj new file mode 100644 index 00000000..bd5a6157 --- /dev/null +++ b/dotnet.csproj @@ -0,0 +1,39 @@ + + + + enable + net7.0 + true + true + enable + true + + false + + + + + + + + + + + + + + all + + + + + + + + \ No newline at end of file diff --git a/features/activity/cancel_try_cancel/feature.cs b/features/activity/cancel_try_cancel/feature.cs new file mode 100644 index 00000000..0c1d105c --- /dev/null +++ b/features/activity/cancel_try_cancel/feature.cs @@ -0,0 +1,93 @@ +namespace activity.cancel_try_cancel; + +using Temporalio.Activities; +using Temporalio.Client; +using Temporalio.Exceptions; +using Temporalio.Features.Harness; +using Temporalio.Worker; +using Temporalio.Workflows; + +class Feature : IFeature +{ + public void ConfigureWorker(Runner runner, TemporalWorkerOptions options) => + options.AddWorkflow().AddAllActivities(new MyActivities(runner.Client)); + + [Workflow] + class MyWorkflow + { + private string? activityResult; + + [WorkflowRun] + public async Task RunAsync() + { + // Create token to cancel + using var activityCancel = CancellationTokenSource.CreateLinkedTokenSource(Workflow.CancellationToken); + + // Start activity + var activityTask = Workflow.ExecuteActivityAsync( + (MyActivities act) => act.CancellableActivity(), + new() + { + ScheduleToCloseTimeout = TimeSpan.FromMinutes(1), + HeartbeatTimeout = TimeSpan.FromSeconds(5), + RetryPolicy = new() { MaximumAttempts = 1 }, + CancellationType = ActivityCancellationType.TryCancel, + CancellationToken = activityCancel.Token, + }); + + // Sleep for short time (force task turnover) + await Workflow.DelayAsync(1); + + // Cancel and confirm the activity errors with the cancel + activityCancel.Cancel(); + try + { + await activityTask; + throw new ApplicationFailureException("Activity should have thrown cancellation error"); + } + catch (ActivityFailureException e) when (e.InnerException is CanceledFailureException) + { + } + + // Confirm signal is cancelled + await Workflow.WaitConditionAsync(() => activityResult is not null); + if (activityResult != "cancelled") + { + throw new ApplicationFailureException($"Expected cancelled, got {activityResult}"); + } + } + + [WorkflowSignal] + public async Task SetActivityResultAsync(string res) => activityResult = res; + } + + class MyActivities + { + private readonly ITemporalClient client; + + public MyActivities(ITemporalClient client) => this.client = client; + + [Activity] + public async Task CancellableActivity() + { + // Heartbeat every second for a minute + var result = "timeout"; + try + { + for (int i = 0; i < 60; i++) + { + await Task.Delay(1000, ActivityExecutionContext.Current.CancellationToken); + ActivityExecutionContext.Current.Heartbeat(); + } + } + catch (OperationCanceledException) + { + result = "cancelled"; + } + + // Send result as signal to workflow + await client.GetWorkflowHandle(ActivityExecutionContext.Current.Info.WorkflowId). + SignalAsync(wf => wf.SetActivityResultAsync(result)); + } + } +} \ No newline at end of file diff --git a/harness/dotnet/Temporalio.Features.Harness/App.cs b/harness/dotnet/Temporalio.Features.Harness/App.cs new file mode 100644 index 00000000..4df91d79 --- /dev/null +++ b/harness/dotnet/Temporalio.Features.Harness/App.cs @@ -0,0 +1,105 @@ +namespace Temporalio.Features.Harness; + +using System.CommandLine; +using System.CommandLine.Invocation; +using Temporalio.Client; + +/// +/// Main application that can parse args and run command. +/// +public static class App +{ + private static readonly Option serverOption = new( + name: "--server", + description: "The host:port of the server") { IsRequired = true }; + + private static readonly Option namespaceOption = new( + name: "--namespace", + description: "The namespace to use") { IsRequired = true }; + + private static readonly Option clientCertPathOption = new( + name: "--client-cert-path", + description: "Path to a client certificate for TLS"); + + private static readonly Option clientKeyPathOption = new( + name: "--client-key-path", + description: "Path to a client key for TLS"); + + private static readonly Argument> featuresArgument = new( + name: "features", + parse: result => result.Tokens.Select(token => + { + var pieces = token.Value.Split(':', 2); + if (pieces.Length != 2) + { + throw new ArgumentException("Feature must be dir + ':' + task queue"); + } + return (pieces[0], pieces[1]); + }).ToList(), + description: "Features as dir + ':' + task queue") { Arity = ArgumentArity.OneOrMore }; + + /// + /// Run this harness with the given args. + /// + /// CLI args. + /// Task for completion. + public static Task RunAsync(string[] args) => CreateCommand().InvokeAsync(args); + + private static Command CreateCommand() + { + var cmd = new RootCommand(".NET features harness"); + cmd.AddOption(serverOption); + cmd.AddOption(namespaceOption); + cmd.AddOption(clientCertPathOption); + cmd.AddOption(clientKeyPathOption); + cmd.AddArgument(featuresArgument); + cmd.SetHandler(RunCommandAsync); + return cmd; + } + + private static async Task RunCommandAsync(InvocationContext ctx) + { + // Create logger factory + using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + })); + var logger = loggerFactory.CreateLogger(typeof(App)); + + // Connect a client + var client = await TemporalClient.ConnectAsync(new(ctx.ParseResult.GetValueForOption(serverOption)!) + { + Namespace = ctx.ParseResult.GetValueForOption(namespaceOption)!, + Tls = ctx.ParseResult.GetValueForOption(clientCertPathOption) is not {} certPath ? null : new() + { + ClientCert = File.ReadAllBytes(certPath.FullName), + ClientPrivateKey = File.ReadAllBytes(ctx.ParseResult.GetValueForOption(clientKeyPathOption)?.FullName ?? + throw new ArgumentException("Missing key with cert")), + }, + }); + + // Go over each feature, calling the runner for it + var failureCount = 0; + foreach (var (dir, taskQueue) in ctx.ParseResult.GetValueForArgument(featuresArgument)) + { + var feature = PreparedFeature.AllFeatures.SingleOrDefault(feature => feature.Dir == dir) ?? + throw new InvalidOperationException($"Unable to find feature for dir {dir}"); + try + { + await new Runner(client, taskQueue, feature, loggerFactory).RunAsync(ctx.GetCancellationToken()); + } + catch(Exception e) + { + logger.LogError(e, "Feature {Feature} failed", feature.Dir); + failureCount++; + } + } + if (failureCount > 0) + { + throw new InvalidOperationException($"{failureCount} feature(s) failed"); + } + logger.LogInformation("All features passed"); + } +} \ No newline at end of file diff --git a/harness/dotnet/Temporalio.Features.Harness/IFeature.cs b/harness/dotnet/Temporalio.Features.Harness/IFeature.cs new file mode 100644 index 00000000..3f50ef2b --- /dev/null +++ b/harness/dotnet/Temporalio.Features.Harness/IFeature.cs @@ -0,0 +1,47 @@ +namespace Temporalio.Features.Harness; + +using Temporalio.Client; +using Temporalio.Worker; + +/// +/// Interface that must be implemented by all features. +/// +public interface IFeature +{ + /// + /// Configure the worker options. This is where workflows and activities + /// should be added. + /// + /// Current runner. + /// Options to mutate. + void ConfigureWorker(Runner runner, TemporalWorkerOptions options); + + /// + /// Execute the feature and optionally return a handle. If no handle is + /// returned, and + /// will not be called. The default + /// implementation expects a single parameterless workflow to be on the + /// worker and then starts it. + /// + /// Current runner. + /// Task with handle or null. + async Task ExecuteAsync(Runner runner) => await runner.StartSingleParameterlessWorkflowAsync(); + + /// + /// Check result for the given workflow handle. The default implementation + /// just gets the result to make sure it didn't fail. + /// + /// Current runner. + /// Workflow handle. + /// Task for completion. + Task CheckResultAsync(Runner runner, WorkflowHandle handle) => handle.GetResultAsync(); + + /// + /// Check history for the given workflow handle. The default implementation + /// just checks current history via replay. + /// + /// Current runner. + /// Workflow handle. + /// Task for completion. + Task CheckHistoryAsync(Runner runner, WorkflowHandle handle) => runner.CheckCurrentHistoryAsync(handle); +} \ No newline at end of file diff --git a/harness/dotnet/Temporalio.Features.Harness/PreparedFeature.cs b/harness/dotnet/Temporalio.Features.Harness/PreparedFeature.cs new file mode 100644 index 00000000..786a6089 --- /dev/null +++ b/harness/dotnet/Temporalio.Features.Harness/PreparedFeature.cs @@ -0,0 +1,25 @@ +namespace Temporalio.Features.Harness; + +/// +/// Feature type with directory. +/// +/// Type for the feature. +public record PreparedFeature(Type FeatureType) +{ + /// + /// Entire set of implementations of across the + /// assemblies. + /// + public static readonly List AllFeatures = + // All types that are not abstract but implement IFeature + AppDomain.CurrentDomain.GetAssemblies(). + SelectMany(a => a.GetTypes()). + Where(t => !t.IsAbstract && typeof(IFeature).IsAssignableFrom(t)). + Select(t => new PreparedFeature(t)). + ToList(); + + /// + /// Gets the relative directory of the feature. + /// + public string Dir { get; } = FeatureType.Namespace!.Replace('.', '/'); +} \ No newline at end of file diff --git a/harness/dotnet/Temporalio.Features.Harness/Runner.cs b/harness/dotnet/Temporalio.Features.Harness/Runner.cs new file mode 100644 index 00000000..580b7ba3 --- /dev/null +++ b/harness/dotnet/Temporalio.Features.Harness/Runner.cs @@ -0,0 +1,104 @@ +namespace Temporalio.Features.Harness; + +using Temporalio.Client; +using Temporalio.Worker; + +/// +/// Runner for running features. +/// +public class Runner +{ + internal Runner( + ITemporalClient client, + string taskQueue, + PreparedFeature feature, + ILoggerFactory loggerFactory) + { + Client = client; + PreparedFeature = feature; + Feature = (IFeature)Activator.CreateInstance(PreparedFeature.FeatureType, true)!; + Logger = loggerFactory.CreateLogger(PreparedFeature.FeatureType); + WorkerOptions = new(taskQueue) { LoggerFactory = loggerFactory }; + Feature.ConfigureWorker(this, WorkerOptions); + } + + public ITemporalClient Client { get; private init; } + + public IFeature Feature { get; private init; } + + public ILogger Logger { get; private init; } + + public PreparedFeature PreparedFeature { get; private init; } + + public TemporalWorkerOptions WorkerOptions { get; private init; } + + /// + /// Run the feature with the given cancellation token. + /// + /// + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + // Run inside worker + Logger.LogInformation("Executing feature {Feature}", PreparedFeature.Dir); + using var worker = new TemporalWorker(Client, WorkerOptions); + await worker.ExecuteAsync(async () => + { + var run = await Feature.ExecuteAsync(this); + if (run == null) + { + Logger.LogInformation("Feature {Feature} returned null", PreparedFeature.Dir); + return; + } + Logger.LogInformation("Checking result of feature {Feature}", PreparedFeature.Dir); + await Feature.CheckResultAsync(this, run); + await Feature.CheckHistoryAsync(this, run); + }, cancellationToken); + } + + /// + /// Expects a single parameterless workflow on the worker and starts it. + /// + /// Workflow handle for the started run. + public Task StartSingleParameterlessWorkflowAsync() + { + var workflow = WorkerOptions.Workflows.SingleOrDefault() ?? + throw new InvalidOperationException("Must have a single workflow"); + return Client.StartWorkflowAsync( + workflow.Name!, + Array.Empty(), + new(id: $"{PreparedFeature.Dir}-{Guid.NewGuid()}", taskQueue: WorkerOptions.TaskQueue!) + { + ExecutionTimeout = TimeSpan.FromMinutes(1) + }); + } + + /// + /// Checks the current history for the given handle using the replayer. + /// + /// Workflow handle. + /// Task for completion. + public async Task CheckCurrentHistoryAsync(WorkflowHandle handle) + { + Logger.LogInformation("Checking current history of feature {Feature}", PreparedFeature.Dir); + // Grab the history and replay + var replayerOptions = new WorkflowReplayerOptions() + { + LoggerFactory = WorkerOptions.LoggerFactory!, + Namespace = Client.Options.Namespace, + TaskQueue = WorkerOptions.TaskQueue!, + }; + foreach (var workflow in WorkerOptions.Workflows) + { + replayerOptions.AddWorkflow(workflow); + } + try + { + await new WorkflowReplayer(replayerOptions).ReplayWorkflowAsync(await handle.FetchHistoryAsync()); + } + catch (Exception e) + { + throw new InvalidOperationException("Replay failed", e); + } + } +} \ No newline at end of file diff --git a/sdkbuild/dotnet.go b/sdkbuild/dotnet.go new file mode 100644 index 00000000..8e10137a --- /dev/null +++ b/sdkbuild/dotnet.go @@ -0,0 +1,144 @@ +package sdkbuild + +import ( + "context" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// BuildDotNetProgramOptions are options for BuildDotNetProgram. +type BuildDotNetProgramOptions struct { + // Directory that will have a temporary directory created underneath. + BaseDir string + // Required version. If it contains a slash, it is assumed to be a path to the + // base of the repo (and will have a src/Temporalio/Temporalio.csproj child). + // Otherwise it is a NuGet version. + Version string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // Required Program.cs content. If not set, no Program.cs is created (so it) + ProgramContents string + // Required csproj content. This should not contain a dependency on Temporalio + // because this adds a package/project reference near the end. + CsprojContents string +} + +// DotNetProgram is a .NET-specific implementation of Program. +type DotNetProgram struct { + dir string +} + +var _ Program = (*DotNetProgram)(nil) + +func BuildDotNetProgram(ctx context.Context, options BuildDotNetProgramOptions) (*DotNetProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.Version == "" { + return nil, fmt.Errorf("version required") + } else if options.ProgramContents == "" { + return nil, fmt.Errorf("program contents required") + } else if options.CsprojContents == "" { + return nil, fmt.Errorf("csproj contents required") + } + + // Create temp dir if needed that we will remove if creating is unsuccessful + success := false + var dir string + if options.DirName != "" { + dir = filepath.Join(options.BaseDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.BaseDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + defer func() { + if !success { + // Intentionally swallow error + _ = os.RemoveAll(dir) + } + }() + } + + // Create program.csproj + var depLine string + // Slash means it is a path + if strings.ContainsAny(options.Version, `/\`) { + // Get absolute path of csproj file + absCsproj, err := filepath.Abs(filepath.Join(options.Version, "src/Temporalio/Temporalio.csproj")) + if err != nil { + return nil, fmt.Errorf("cannot make absolute path from version: %w", err) + } else if _, err := os.Stat(absCsproj); err != nil { + return nil, fmt.Errorf("cannot find version path of %v: %w", absCsproj, err) + } + depLine = `` + // Need to build this csproj first + cmd := exec.CommandContext(ctx, "dotnet", "build", absCsproj) + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed dotnet build of csproj in version: %w", err) + } + } else { + depLine = `` + } + // Add the item group for the Temporalio dep just before the ending project tag + endProjectTag := strings.LastIndex(options.CsprojContents, "") + if endProjectTag == -1 { + return nil, fmt.Errorf("no ending project tag found in csproj contents") + } + csproj := options.CsprojContents[:endProjectTag] + "\n \n " + depLine + + "\n \n" + options.CsprojContents[endProjectTag:] + if err := os.WriteFile(filepath.Join(dir, "program.csproj"), []byte(csproj), 0644); err != nil { + return nil, fmt.Errorf("failed writing program.csproj: %w", err) + } + + // Create Program.cs + if err := os.WriteFile(filepath.Join(dir, "Program.cs"), []byte(options.ProgramContents), 0644); err != nil { + return nil, fmt.Errorf("failed writing Program.cs: %w", err) + } + + // Build it into build folder + cmd := exec.CommandContext(ctx, "dotnet", "build", "--output", "build") + cmd.Dir = dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed dotnet build: %w", err) + } + + // All good + success = true + return &DotNetProgram{dir}, nil +} + +// DotNetProgramFromDir recreates the Go program from a Dir() result of a +// BuildDotNetProgram(). +func DotNetProgramFromDir(dir string) (*DotNetProgram, error) { + // Quick sanity check on the presence of program.csproj + if _, err := os.Stat(filepath.Join(dir, "program.csproj")); err != nil { + return nil, fmt.Errorf("failed finding program.csproj in dir: %w", err) + } + return &DotNetProgram{dir}, nil +} + +// Dir is the directory to run in. +func (d *DotNetProgram) Dir() string { return d.dir } + +// NewCommand makes a new command for the given args. +func (d *DotNetProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + exe := "./build/program" + if runtime.GOOS == "windows" { + exe += ".exe" + } + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = d.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +}