diff --git a/.github/workflows/all-docker-images.yaml b/.github/workflows/all-docker-images.yaml index 1f09a05b..458ac320 100644 --- a/.github/workflows/all-docker-images.yaml +++ b/.github/workflows/all-docker-images.yaml @@ -25,6 +25,9 @@ on: cs-ver: description: .NET SDK ver to build. Skipped if not specified. Must start with v. type: string + rb-ver: + description: Ruby 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 @@ -58,6 +61,9 @@ on: cs-ver: description: .NET SDK ver to build. Skipped if not specified. Must start with v. type: string + rb-ver: + description: Ruby 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 @@ -137,3 +143,14 @@ jobs: semver-latest: major do-push: ${{ inputs.do-push }} skip-cloud: ${{ inputs.skip-cloud }} + + build-rb-docker-images: + if: inputs.rb-ver + uses: ./.github/workflows/docker-images.yaml + secrets: inherit + with: + lang: rb + sdk-version: ${{ inputs.rb-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 ebab8510..25bf1090 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,9 @@ on: # rebuild any PRs and main branch changes dotnet_sdk_version: default: '' type: string + ruby_sdk_version: + default: '' + type: string permissions: contents: read @@ -48,6 +51,7 @@ jobs: php_latest: ${{ steps.latest_version.outputs.php_latest }} python_latest: ${{ steps.latest_version.outputs.python_latest }} csharp_latest: ${{ steps.latest_version.outputs.csharp_latest }} + ruby_latest: ${{ steps.latest_version.outputs.ruby_latest }} steps: - name: Print build information run: 'echo head_ref: "$GITHUB_HEAD_REF", ref: "$GITHUB_REF", os: ${{ matrix.os }}' @@ -60,49 +64,64 @@ jobs: - name: Get the latest release version id: latest_version + env: + INPUT_GO_SDK_VERSION: ${{ github.event.inputs.go_sdk_version }} + INPUT_TS_SDK_VERSION: ${{ github.event.inputs.typescript_sdk_version }} + INPUT_JAVA_SDK_VERSION: ${{ github.event.inputs.java_sdk_version }} + INPUT_PHP_SDK_VERSION: ${{ github.event.inputs.php_sdk_version }} + INPUT_PYTHON_SDK_VERSION: ${{ github.event.inputs.python_sdk_version }} + INPUT_DOTNET_SDK_VERSION: ${{ github.event.inputs.dotnet_sdk_version }} + INPUT_RUBY_SDK_VERSION: ${{ github.event.inputs.ruby_sdk_version }} run: | - go_latest="${{ github.event.inputs.go_sdk_version }}" + go_latest="$INPUT_GO_SDK_VERSION" if [ -z "$go_latest" ]; then go_latest=$(./temporal-features latest-sdk-version --lang go) echo "Derived latest Go SDK release version: $go_latest" fi echo "go_latest=$go_latest" >> $GITHUB_OUTPUT - typescript_latest="${{ github.event.inputs.typescript_sdk_version }}" + typescript_latest="$INPUT_TS_SDK_VERSION" if [ -z "$typescript_latest" ]; then typescript_latest=$(./temporal-features latest-sdk-version --lang ts) echo "Derived latest Typescript SDK release version: $typescript_latest" fi echo "typescript_latest=$typescript_latest" >> $GITHUB_OUTPUT - java_latest="${{ github.event.inputs.java_sdk_version }}" + java_latest="$INPUT_JAVA_SDK_VERSION" if [ -z "$java_latest" ]; then java_latest=$(./temporal-features latest-sdk-version --lang java) echo "Derived latest Java SDK release version: $java_latest" fi echo "java_latest=$java_latest" >> $GITHUB_OUTPUT - php_latest="${{ github.event.inputs.php_sdk_version }}" + php_latest="$INPUT_PHP_SDK_VERSION" if [ -z "$php_latest" ]; then php_latest=$(./temporal-features latest-sdk-version --lang php) echo "Derived latest PHP SDK release version: $php_latest" fi echo "php_latest=$php_latest" >> $GITHUB_OUTPUT - python_latest="${{ github.event.inputs.python_sdk_version }}" + python_latest="$INPUT_PYTHON_SDK_VERSION" if [ -z "$python_latest" ]; then python_latest=$(./temporal-features latest-sdk-version --lang py) echo "Derived latest Python SDK release version: $python_latest" fi echo "python_latest=$python_latest" >> $GITHUB_OUTPUT - csharp_latest="${{ github.event.inputs.dotnet_sdk_version }}" + csharp_latest="$INPUT_DOTNET_SDK_VERSION" if [ -z "$csharp_latest" ]; then csharp_latest=$(./temporal-features latest-sdk-version --lang cs) echo "Derived latest Dotnet SDK release version: $csharp_latest" fi echo "csharp_latest=$csharp_latest" >> $GITHUB_OUTPUT + ruby_latest="$INPUT_RUBY_SDK_VERSION" + if [ -z "$ruby_latest" ]; then + ruby_latest=$(./temporal-features latest-sdk-version --lang rb) + echo "Derived latest Ruby SDK release version: $ruby_latest" + fi + echo "ruby_latest=$ruby_latest" >> $GITHUB_OUTPUT + build-ts: strategy: fail-fast: true @@ -187,6 +206,23 @@ jobs: - run: dotnet build - run: dotnet test + build-ruby: + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + 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@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + - run: gem install rubocop + - run: rubocop + feature-tests-ts: permissions: contents: read @@ -259,6 +295,18 @@ jobs: features-repo-ref: ${{ github.head_ref }} features-repo-path: ${{ github.event.pull_request.head.repo.full_name }} + feature-tests-ruby: + permissions: + contents: read + actions: read + needs: build-go + uses: ./.github/workflows/ruby.yaml + with: + version: ${{ needs.build-go.outputs.ruby_latest }} + 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: needs: build-go uses: ./.github/workflows/all-docker-images.yaml @@ -271,3 +319,4 @@ jobs: php-ver: 'v${{ needs.build-go.outputs.php_latest }}' py-ver: 'v${{ needs.build-go.outputs.python_latest }}' cs-ver: 'v${{ needs.build-go.outputs.csharp_latest }}' + rb-ver: 'v${{ needs.build-go.outputs.ruby_latest }}' diff --git a/.github/workflows/ruby.yaml b/.github/workflows/ruby.yaml new file mode 100644 index 00000000..ab150609 --- /dev/null +++ b/.github/workflows/ruby.yaml @@ -0,0 +1,124 @@ +name: Ruby Features Testing +on: + workflow_call: + inputs: + version: + required: true + type: string + # When true, the default version will be used (actually it's the latest tag) + 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 + +permissions: + contents: read + actions: read + +jobs: + test: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + defaults: + run: + working-directory: ./features + steps: + - name: Print git info + run: 'echo head_ref: "$GITHUB_HEAD_REF", ref: "$GITHUB_REF", Ruby sdk version: "$INPUT_VERSION"' + working-directory: '.' + env: + INPUT_VERSION: ${{ inputs.version }} + + - name: Download docker artifacts + if: ${{ inputs.docker-image-artifact-name }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.docker-image-artifact-name }} + path: /tmp/server-docker + + - name: Load server Docker Images + if: ${{ inputs.docker-image-artifact-name }} + run: | + docker load --input /tmp/server-docker/temporal-server.tar + docker load --input /tmp/server-docker/temporal-admin-tools.tar + working-directory: '.' + + - name: Override IMAGE_TAG environment variable + if: ${{ inputs.docker-image-artifact-name }} + run: | + image_tag=latest + # image_tag won't exist on older builds (like 1.22.0), so default to latest + if [ -f /tmp/server-docker/image_tag ]; then + image_tag=$(cat /tmp/server-docker/image_tag) + fi + echo "IMAGE_TAG=${image_tag}" >> $GITHUB_ENV + working-directory: '.' + + - name: Checkout SDK features repo + uses: actions/checkout@v4 + with: + path: features + repository: ${{ inputs.features-repo-path }} + ref: ${{ inputs.features-repo-ref }} + + - uses: actions/setup-go@v2 + with: + go-version: '^1.22' + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + + - name: Start containerized server and dependencies + id: start-server + if: inputs.docker-image-artifact-name + run: | + docker compose \ + -f ./dockerfiles/docker-compose.yml \ + up -d cassandra elasticsearch temporal-admin-tools temporal-server temporal-create-namespace + + - name: Show all container logs + if: always() + run: | + echo "=== All container logs ===" + docker compose \ + -f ./dockerfiles/docker-compose.yml \ + logs + working-directory: ./features + + - name: Run SDK-features tests directly + if: inputs.docker-image-artifact-name == '' + env: + INPUT_VERSION: ${{ inputs.version-is-repo-ref && '' || inputs.version }} + run: go run . run --lang rb --version "$INPUT_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 ./dockerfiles/docker-compose.yml \ + up --no-log-prefix --exit-code-from features-tests-rb features-tests-rb + + - name: Show all container logs before teardown + if: always() && inputs.docker-image-artifact-name + run: | + echo "=== All container logs before teardown ===" + docker compose \ + -f ./dockerfiles/docker-compose.yml \ + logs + working-directory: ./features + + - name: Tear down docker compose + if: inputs.docker-image-artifact-name && (success() || failure()) + run: docker compose -f ./dockerfiles/docker-compose.yml down -v diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 00000000..b139eeb4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,58 @@ +inherit_mode: + merge: + - Exclude + +AllCops: + NewCops: enable + TargetRubyVersion: 4.0 + SuggestExtensions: false + Include: + - harness/ruby/**/*.rb + - harness/ruby/**/*.gemspec + - features/**/feature.rb + Exclude: + - vendor/**/* + - tmp/**/* + +# Don't need super for activities/workflows +Lint/MissingSuper: + AllowedParentClasses: + - Temporalio::Activity::Definition + - Temporalio::Workflow::Definition + +# Harness methods can be longer +Metrics/AbcSize: + Max: 100 + +# Harness blocks can be longer +Metrics/BlockLength: + Max: 50 + +# Harness classes can be longer +Metrics/ClassLength: + Max: 300 + +# Harness methods can be longer +Metrics/MethodLength: + Max: 100 + +Metrics/CyclomaticComplexity: + Max: 30 + +Metrics/PerceivedComplexity: + Max: 30 + +# Feature files are small and self-contained, no docs needed +Style/Documentation: + Enabled: false + +Style/DocumentationMethod: + Enabled: false + +# We are ok with large amount of keyword args +Metrics/ParameterLists: + CountKeywordArgs: false + +# We want our require lists to be in order +Style/RequireOrder: + Enabled: true diff --git a/README.md b/README.md index 9f000277..e7f88ec0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Prerequisites: - [.NET](https://dotnet.microsoft.com) 7+ - [PHP](https://www.php.net/) 8.1+ - [Composer](https://getcomposer.org/) +- [Ruby](https://www.ruby-lang.org/) 4.0+ Command: @@ -38,7 +39,7 @@ 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`, `php`, `py`, or `cs`. `VERSION` is per SDK and if left off, uses the latest version set for +`LANG` can be `go`, `java`, `ts`, `php`, `py`, `cs`, or `rb`. `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 @@ -153,6 +154,7 @@ func HelloUniverse() { mind when writing features. - A Python feature should be in `feature.py`. +- A Ruby feature should be in `feature.rb`. - Add a README.md to each feature directory. - README should have a title summarizing the feature (only first letter needs to be in title case), then a short paragraph explaining the feature and its purpose, and then optionally another paragraph explaining details of the diff --git a/cmd/prepare.go b/cmd/prepare.go index 494b2834..8af5ff1e 100644 --- a/cmd/prepare.go +++ b/cmd/prepare.go @@ -93,6 +93,8 @@ func (p *Preparer) Prepare(ctx context.Context) error { _, err = p.BuildPythonProgram(ctx) case "cs": _, err = p.BuildDotNetProgram(ctx) + case "rb": + _, err = p.BuildRubyProgram(ctx) default: err = fmt.Errorf("unrecognized language") } diff --git a/cmd/run.go b/cmd/run.go index df7933d9..01c51130 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -349,6 +349,16 @@ func (r *Runner) Run(ctx context.Context, patterns []string) error { if err == nil { err = r.RunDotNetExternal(ctx, run) } + case "rb": + if r.config.DirName != "" { + r.program, err = sdkbuild.RubyProgramFromDir( + filepath.Join(r.rootDir, r.config.DirName), + r.rootDir, + ) + } + if err == nil { + err = r.RunRubyExternal(ctx, run) + } default: err = fmt.Errorf("unrecognized language") } @@ -587,15 +597,17 @@ func (r *Runner) destroyTempDir() { func normalizeLangName(lang string) (string, error) { // Normalize to file extension switch lang { - case "go", "java", "ts", "php", "py", "cs": + case "go", "java", "ts", "php", "py", "cs", "rb": case "typescript": lang = "ts" case "python": lang = "py" case "dotnet", "csharp": lang = "cs" + case "ruby": + lang = "rb" default: - return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs", lang) + return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs or rb", lang) } return lang, nil } @@ -603,15 +615,17 @@ func normalizeLangName(lang string) (string, error) { func expandLangName(lang string) (string, error) { // Expand to lang name switch lang { - case "go", "java", "typescript", "php", "python": + case "go", "java", "typescript", "php", "python", "ruby": case "ts": lang = "typescript" case "py": lang = "python" case "cs": lang = "dotnet" + case "rb": + lang = "ruby" default: - return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs", lang) + return "", fmt.Errorf("invalid language %q, must be one of: go or java or ts or py or cs or rb", lang) } return lang, nil } @@ -619,7 +633,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' or 'cs')", + Usage: "SDK language to run ('go' or 'java' or 'ts' or 'py' or 'cs' or 'rb')", Required: true, Destination: destination, } diff --git a/cmd/run_ruby.go b/cmd/run_ruby.go new file mode 100644 index 00000000..e3c503d6 --- /dev/null +++ b/cmd/run_ruby.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/temporalio/features/harness/go/cmd" + "github.com/temporalio/features/sdkbuild" +) + +// BuildRubyProgram prepares a Ruby 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) BuildRubyProgram(ctx context.Context) (sdkbuild.Program, error) { + p.log.Info("Building Ruby project", "DirName", p.config.DirName) + + // Get version from harness/ruby/Gemfile if not present + version := p.config.Version + if version == "" { + b, err := os.ReadFile(filepath.Join(p.rootDir, "harness", "ruby", "Gemfile")) + if err != nil { + return nil, fmt.Errorf("failed reading harness/ruby/Gemfile: %w", err) + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, `"temporalio"`) { + // Extract version from: gem "temporalio", "~> 1.2" + parts := strings.Split(line, ",") + if len(parts) >= 2 { + version = strings.TrimSpace(parts[1]) + version = strings.Trim(version, `"'`) + } + break + } + } + if version == "" { + return nil, fmt.Errorf("version not found in harness/ruby/Gemfile") + } + } + + prog, err := sdkbuild.BuildRubyProgram(ctx, sdkbuild.BuildRubyProgramOptions{ + BaseDir: p.rootDir, + DirName: p.config.DirName, + Version: version, + }) + if err != nil { + return nil, fmt.Errorf("failed preparing: %w", err) + } + return prog, nil +} + +// RunRubyExternal runs the Ruby run in an external process. This expects +// the server to already be started. +func (r *Runner) RunRubyExternal(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).BuildRubyProgram(ctx); err != nil { + return err + } + } + + // Build args + args := []string{"--server", r.config.Server, "--namespace", r.config.Namespace} + if r.config.ClientCertPath != "" { + clientCertPath, err := filepath.Abs(r.config.ClientCertPath) + if err != nil { + return err + } + args = append(args, "--client-cert-path", clientCertPath) + } + if r.config.ClientKeyPath != "" { + clientKeyPath, err := filepath.Abs(r.config.ClientKeyPath) + if err != nil { + return err + } + args = append(args, "--client-key-path", clientKeyPath) + } + if r.config.CACertPath != "" { + caCertPath, err := filepath.Abs(r.config.CACertPath) + if err != nil { + return err + } + args = append(args, "--ca-cert-path", caCertPath) + } + if r.config.TLSServerName != "" { + args = append(args, "--tls-server-name", r.config.TLSServerName) + } + if r.config.HTTPProxyURL != "" { + args = append(args, "--http-proxy-url", r.config.HTTPProxyURL) + } + if r.config.SummaryURI != "" { + args = append(args, "--summary-uri", r.config.SummaryURI) + } + args = append(args, run.ToArgs()...) + + // Run + cmd, err := r.program.NewCommand(ctx, args...) + if err == nil { + r.log.Debug("Running Ruby separately", "Args", cmd.Args) + err = cmd.Run() + } + if err != nil { + return fmt.Errorf("failed running: %w", err) + } + return nil +} diff --git a/dockerfiles/docker-compose.yml b/dockerfiles/docker-compose.yml index 6d2a1cbc..c1aaa165 100644 --- a/dockerfiles/docker-compose.yml +++ b/dockerfiles/docker-compose.yml @@ -190,3 +190,12 @@ services: depends_on: temporal-create-namespace: condition: service_completed_successfully + + features-tests-rb: + image: temporaliotest/features:rb + environment: + - WAIT_EXTRA_FOR_NAMESPACE + command: ['--server', 'temporal-server:7233', '--namespace', 'default'] + depends_on: + temporal-create-namespace: + condition: service_completed_successfully diff --git a/dockerfiles/rb.Dockerfile b/dockerfiles/rb.Dockerfile new file mode 100644 index 00000000..9b19d2f4 --- /dev/null +++ b/dockerfiles/rb.Dockerfile @@ -0,0 +1,61 @@ +# Build in a full featured container +FROM ruby:4.0-bookworm as build + +# Install protobuf compiler +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive \ + apt-get install --no-install-recommends --assume-yes \ + protobuf-compiler=3.21.12* libprotobuf-dev=3.21.12* +# 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" + +# Get go compiler +ARG PLATFORM=amd64 +RUN wget -q https://go.dev/dl/go1.22.5.linux-${PLATFORM}.tar.gz \ + && tar -C /usr/local -xzf go1.22.5.linux-${PLATFORM}.tar.gz + +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 ./ + +# Build the CLI +RUN CGO_ENABLED=0 /usr/local/go/bin/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} + +# Override BUNDLE_APP_CONFIG so bundler reads .bundle/config from the prepared dir +# (the Ruby Docker image sets this to /usr/local/bundle which is lost in multi-stage builds) +ENV BUNDLE_APP_CONFIG=.bundle + +# Prepare the feature for running. +RUN CGO_ENABLED=0 ./temporal-features prepare --lang rb --dir prepared --version "$SDK_VERSION" + +# Copy the CLI and prepared feature to a smaller container for running +FROM ruby:4.0-slim-bookworm + +COPY --from=build /app/temporal-features /app/temporal-features +COPY --from=build /app/features /app/features +COPY --from=build /app/prepared /app/prepared +COPY --from=build /app/harness/ruby /app/harness/ruby +COPY --from=build /app/${REPO_DIR_OR_PLACEHOLDER} /app/${REPO_DIR_OR_PLACEHOLDER} + +# Override BUNDLE_APP_CONFIG so bundler reads .bundle/config from the prepared dir +ENV BUNDLE_APP_CONFIG=.bundle + +# Use entrypoint instead of command to "bake" the default command options +ENTRYPOINT ["/app/temporal-features", "run", "--lang", "rb", "--prepared-dir", "prepared"] diff --git a/features/activity/basic_no_workflow_timeout/feature.rb b/features/activity/basic_no_workflow_timeout/feature.rb new file mode 100644 index 00000000..f7ae4401 --- /dev/null +++ b/features/activity/basic_no_workflow_timeout/feature.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'temporalio/activity' +require 'temporalio/workflow' + +require 'harness' + +class EchoActivity < Temporalio::Activity::Definition + def execute + 'echo' + end +end + +class BasicNoWorkflowTimeoutWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.execute_activity(EchoActivity, schedule_to_close_timeout: 60) + Temporalio::Workflow.execute_activity(EchoActivity, start_to_close_timeout: 60) + end +end + +Harness.register_feature( + workflows: [BasicNoWorkflowTimeoutWorkflow], + activities: [EchoActivity], + expect_run_result: 'echo' +) diff --git a/features/signal/basic/feature.rb b/features/signal/basic/feature.rb new file mode 100644 index 00000000..e1308685 --- /dev/null +++ b/features/signal/basic/feature.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'securerandom' + +require 'temporalio/workflow' + +require 'harness' + +class BasicSignalWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.wait_condition { @state } + end + + workflow_signal + def my_signal(arg) + @state = arg + end +end + +start = proc do |client, task_queue, _feature| + handle = client.start_workflow( + BasicSignalWorkflow, + id: "signal-basic-#{SecureRandom.uuid}", + task_queue: task_queue, + execution_timeout: 60 + ) + handle.signal(BasicSignalWorkflow.my_signal, 'arg') + handle +end + +Harness.register_feature( + workflows: [BasicSignalWorkflow], + expect_run_result: 'arg', + start: start +) diff --git a/harness/ruby/Gemfile b/harness/ruby/Gemfile new file mode 100644 index 00000000..04a3b2fc --- /dev/null +++ b/harness/ruby/Gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +gem 'temporalio', '~> 1.2' + +group :development do + gem 'rubocop' +end diff --git a/harness/ruby/harness.gemspec b/harness/ruby/harness.gemspec new file mode 100644 index 00000000..75caf2c7 --- /dev/null +++ b/harness/ruby/harness.gemspec @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = 'harness' + spec.version = '0.1.0' + spec.authors = ['Temporal Technologies'] + spec.summary = 'Temporal features test harness for Ruby' + spec.files = Dir['lib/**/*.rb'] + spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 4.0.0' + spec.metadata['rubygems_mfa_required'] = 'true' +end diff --git a/harness/ruby/lib/harness.rb b/harness/ruby/lib/harness.rb new file mode 100644 index 00000000..45131343 --- /dev/null +++ b/harness/ruby/lib/harness.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Harness + Feature = Struct.new( + :workflows, + :activities, + :expect_run_result, + :expect_activity_error, + :start_callback, + :check_result_callback, + keyword_init: true + ) + + @features = {} + + class << self + attr_reader :features + end + + def self.register_feature( + workflows:, + activities: [], + expect_run_result: nil, + expect_activity_error: nil, + start: nil, + check_result: nil + ) + rel_dir = caller_feature_dir(caller_locations(1, 1).first.path) + @features[rel_dir] = Feature.new( + workflows: workflows, + activities: activities, + expect_run_result: expect_run_result, + expect_activity_error: expect_activity_error, + start_callback: start, + check_result_callback: check_result + ) + end + + def self.caller_feature_dir(file_path) + parts = file_path.gsub('\\', '/').split('/') + features_idx = parts.rindex('features') + raise "Cannot determine feature dir from path: #{file_path}" unless features_idx + + parts[(features_idx + 1)...-1].join('/') + end + + class SkipFeature < StandardError; end +end diff --git a/harness/ruby/runner.rb b/harness/ruby/runner.rb new file mode 100644 index 00000000..b824a7da --- /dev/null +++ b/harness/ruby/runner.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'json' +require 'optparse' +require 'securerandom' +require 'socket' +require 'uri' + +require 'temporalio/client' +require 'temporalio/worker' + +require 'harness' + +module Harness + class Runner + def initialize(argv) + @features_arg = [] + parse_args(argv) + end + + def run + summary_io = open_summary + failed_features = [] + + @features_arg.each do |feature_and_queue| + rel_dir, task_queue = feature_and_queue.split(':', 2) + entry = { name: rel_dir, outcome: 'PASSED', message: '' } + + begin + run_feature(rel_dir, task_queue) + rescue Harness::SkipFeature => e + entry[:outcome] = 'SKIPPED' + entry[:message] = e.message + warn "Feature #{rel_dir} skipped: #{e.message}" + rescue StandardError => e + entry[:outcome] = 'FAILED' + entry[:message] = e.message + warn "Feature #{rel_dir} failed: #{e.class}: #{e.message}" + warn e.backtrace.first(10).join("\n") if e.backtrace + failed_features << rel_dir + end + + write_summary_entry(summary_io, entry) + end + + summary_io&.close + + if failed_features.any? + warn "#{failed_features.size} feature(s) failed: #{failed_features.join(', ')}" + exit 1 + end + + warn 'All features passed' + end + + private + + def parse_args(argv) + parser = OptionParser.new do |opts| + opts.banner = 'Usage: runner.rb [options] feature:taskqueue ...' + + opts.on('--server HOST', 'The host:port of the server') { |v| @server = v } + opts.on('--namespace NS', 'The namespace to use') { |v| @namespace = v } + opts.on('--client-cert-path PATH', 'Path to a client certificate for TLS') { |v| @client_cert_path = v } + opts.on('--client-key-path PATH', 'Path to a client key for TLS') { |v| @client_key_path = v } + opts.on('--ca-cert-path PATH', 'Path to a CA certificate') { |v| @ca_cert_path = v } + opts.on('--tls-server-name NAME', 'TLS server name override') { |v| @tls_server_name = v } + opts.on('--http-proxy-url URL', 'HTTP proxy URL') { |v| @http_proxy_url = v } + opts.on('--summary-uri URI', 'Where to stream the test summary JSONL') { |v| @summary_uri = v } + end + + @features_arg = parser.parse(argv) + + raise ArgumentError, 'Missing --server' unless @server + raise ArgumentError, 'Missing --namespace' unless @namespace + raise ArgumentError, 'No features specified' if @features_arg.empty? + end + + def open_summary + return nil unless @summary_uri + + uri = URI.parse(@summary_uri) + case uri.scheme + when 'tcp' + TCPSocket.new(uri.host, uri.port) + when 'file' + File.open(uri.path, 'w') + else + raise "Unsupported summary scheme: #{uri.scheme}" + end + end + + def write_summary_entry(summary_io, entry) + return unless summary_io + + summary_io.puts(JSON.generate(entry)) + summary_io.flush + end + + def connect_client + connect_options = {} + + if @client_cert_path + raise ArgumentError, 'Client cert specified, but not client key!' unless @client_key_path + + connect_options[:tls] = build_tls_options + end + + if @http_proxy_url + connect_options[:http_connect_proxy] = + Temporalio::Client::Connection::HTTPConnectProxyOptions.new(target_host: @http_proxy_url) + end + + Temporalio::Client.connect(@server, @namespace, **connect_options) + end + + def build_tls_options + tls_opts = { + client_cert: File.binread(@client_cert_path), + client_private_key: File.binread(@client_key_path) + } + tls_opts[:server_root_ca_cert] = File.binread(@ca_cert_path) if @ca_cert_path + tls_opts[:domain] = @tls_server_name if @tls_server_name + Temporalio::Client::Connection::TLSOptions.new(**tls_opts) + end + + def run_feature(rel_dir, task_queue) + warn "Running feature #{rel_dir}" + + load_feature_file(rel_dir) + feature = Harness.features[rel_dir] + raise "Feature #{rel_dir} not registered after loading" unless feature + + client = connect_client + execute_feature(client, feature, rel_dir, task_queue) + end + + def load_feature_file(rel_dir) + feature_file = File.join(features_root, rel_dir, 'feature.rb') + raise "Feature file not found: #{feature_file}" unless File.exist?(feature_file) + + load feature_file + end + + def execute_feature(client, feature, rel_dir, task_queue) + worker = Temporalio::Worker.new( + client: client, + task_queue: task_queue, + activities: feature.activities, + workflows: feature.workflows + ) + + worker.run do + handle = start_workflow(client, feature, rel_dir, task_queue) + check_result(client, handle, feature) + end + end + + def start_workflow(client, feature, rel_dir, task_queue) + if feature.start_callback + feature.start_callback.call(client, task_queue, feature) + else + start_default_workflow(client, feature, rel_dir, task_queue) + end + end + + def start_default_workflow(client, feature, rel_dir, task_queue) + raise 'Must have exactly one workflow for default start' unless feature.workflows.size == 1 + + client.start_workflow( + feature.workflows.first, + id: "#{rel_dir}-#{SecureRandom.uuid}", + task_queue: task_queue, + execution_timeout: 60 + ) + end + + def check_result(_client, handle, feature) + if feature.check_result_callback + feature.check_result_callback.call(handle, feature) + else + check_default_result(handle, feature) + end + end + + def check_default_result(handle, feature) + result = handle.result + return if feature.expect_run_result.nil? + return if result == feature.expect_run_result + + raise "Expected result #{feature.expect_run_result.inspect}, got #{result.inspect}" + end + + def features_root + File.expand_path('../../features', __dir__) + end + end +end + +Harness::Runner.new(ARGV).run diff --git a/sdkbuild/ruby.go b/sdkbuild/ruby.go new file mode 100644 index 00000000..e51490bb --- /dev/null +++ b/sdkbuild/ruby.go @@ -0,0 +1,171 @@ +package sdkbuild + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// BuildRubyProgramOptions are options for BuildRubyProgram. +type BuildRubyProgramOptions 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 + // Ruby SDK repo. Otherwise it is a specific version (with leading "v" + // trimmed if present). + Version string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + // If present, custom writers that will capture stdout/stderr. + Stdout io.Writer + Stderr io.Writer +} + +// RubyProgram is a Ruby-specific implementation of Program. +type RubyProgram struct { + dir string + source string +} + +var _ Program = (*RubyProgram)(nil) + +// BuildRubyProgram builds a Ruby program. If completed successfully, this +// can be stored and re-obtained via RubyProgramFromDir() with the Dir() value. +func BuildRubyProgram(ctx context.Context, options BuildRubyProgramOptions) (*RubyProgram, error) { + if options.BaseDir == "" { + return nil, fmt.Errorf("base dir required") + } else if options.Version == "" { + return nil, fmt.Errorf("version required") + } + + sourceDir := filepath.Join(options.BaseDir, "harness", "ruby") + + // 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 { + _ = os.RemoveAll(dir) + } + }() + } + + // Skip if already installed + if st, err := os.Stat(filepath.Join(dir, "vendor")); err == nil && st.IsDir() { + return &RubyProgram{dir: dir, source: sourceDir}, nil + } + + executeCommand := func(name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + setupCommandIO(cmd, options.Stdout, options.Stderr) + return cmd.Run() + } + + // Build the Gemfile content + var gemfileContent string + if strings.ContainsAny(options.Version, `/\`) { + // It's a path to a local SDK repo + sdkPath, err := filepath.Abs(options.Version) + if err != nil { + return nil, fmt.Errorf("unable to make sdk path absolute: %w", err) + } + // The gem is in the temporalio/ subdirectory of the SDK repo + gemPath := filepath.Join(sdkPath, "temporalio") + if _, err := os.Stat(filepath.Join(gemPath, "temporalio.gemspec")); err != nil { + // Try the path directly if no temporalio/ subdirectory + gemPath = sdkPath + if _, err := os.Stat(filepath.Join(gemPath, "temporalio.gemspec")); err != nil { + return nil, fmt.Errorf("failed finding temporalio.gemspec in version dir: %w", err) + } + } + gemfileContent = fmt.Sprintf(`source "https://rubygems.org" + +gem "temporalio", path: %q +gem "harness", path: %q +`, gemPath, sourceDir) + } else { + version := strings.TrimPrefix(options.Version, "v") + gemfileContent = fmt.Sprintf(`source "https://rubygems.org" + +gem "temporalio", "%s" +gem "harness", path: %q +`, version, sourceDir) + } + + if err := os.WriteFile(filepath.Join(dir, "Gemfile"), []byte(gemfileContent), 0644); err != nil { + return nil, fmt.Errorf("failed writing Gemfile: %w", err) + } + + // Install dependencies via Bundler into a local vendor directory so the + // prepared dir is self-contained (important for Docker multi-stage builds). + // We write .bundle/config directly because the Ruby Docker image sets + // BUNDLE_APP_CONFIG to /usr/local/bundle, which would cause bundle config + // to write outside the prepared directory. + bundleDir := filepath.Join(dir, ".bundle") + if err := os.MkdirAll(bundleDir, 0755); err != nil { + return nil, fmt.Errorf("failed creating .bundle dir: %w", err) + } + bundleConfig := "---\nBUNDLE_PATH: \"vendor/bundle\"\n" + if err := os.WriteFile(filepath.Join(bundleDir, "config"), []byte(bundleConfig), 0644); err != nil { + return nil, fmt.Errorf("failed writing .bundle/config: %w", err) + } + if err := executeCommand("bundle", "install"); err != nil { + return nil, fmt.Errorf("failed installing dependencies: %w", err) + } + + // When using a local SDK path, compile the native Rust extension + if strings.ContainsAny(options.Version, `/\`) { + sdkPath, _ := filepath.Abs(options.Version) + gemPath := filepath.Join(sdkPath, "temporalio") + if _, err := os.Stat(filepath.Join(gemPath, "Rakefile")); err != nil { + gemPath = sdkPath + } + if _, err := os.Stat(filepath.Join(gemPath, "Rakefile")); err == nil { + compileCmd := exec.CommandContext(ctx, "bundle", "exec", "rake", "compile") + compileCmd.Dir = gemPath + setupCommandIO(compileCmd, options.Stdout, options.Stderr) + if err := compileCmd.Run(); err != nil { + return nil, fmt.Errorf("failed compiling native extension: %w", err) + } + } + } + + success = true + return &RubyProgram{dir: dir, source: sourceDir}, nil +} + +// RubyProgramFromDir recreates the Ruby program from a Dir() result of a +// BuildRubyProgram(). Note, the base directory of dir when it was built must +// also be present. +func RubyProgramFromDir(dir string, rootDir string) (*RubyProgram, error) { + if _, err := os.Stat(filepath.Join(dir, "Gemfile")); err != nil { + return nil, fmt.Errorf("failed finding Gemfile in dir: %w", err) + } + return &RubyProgram{dir: dir, source: filepath.Join(rootDir, "harness", "ruby")}, nil +} + +// Dir is the directory to run in. +func (r *RubyProgram) Dir() string { return r.dir } + +// NewCommand makes a new Ruby command via Bundler. +func (r *RubyProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + args = append([]string{"exec", "ruby", filepath.Join(r.source, "runner.rb")}, args...) + cmd := exec.CommandContext(ctx, "bundle", args...) + cmd.Dir = r.dir + setupCommandIO(cmd, nil, nil) + return cmd, nil +}