diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aead980..a575a625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,11 +157,17 @@ jobs: strategy: fail-fast: false matrix: - sdk: [python] include: - sdk: python project: helloworld dockerfile: dockerfiles/python.Dockerfile + sdk_version_env: PYTHON_SDK_VERSION + sdk_version_prefix: v + - sdk: typescript + project: helloworld + dockerfile: dockerfiles/typescript.Dockerfile + sdk_version_env: TYPESCRIPT_SDK_VERSION + sdk_version_prefix: "" steps: - name: Checkout repo uses: actions/checkout@v4 @@ -173,7 +179,8 @@ jobs: printenv >> $GITHUB_ENV - name: Set ${{ matrix.sdk }} project image metadata run: | - echo "PROJECT_IMAGE_SDK_VERSION=v${PYTHON_SDK_VERSION}" >> "$GITHUB_ENV" + sdk_version_env=${{ matrix.sdk_version_env }} + echo "PROJECT_IMAGE_SDK_VERSION=${{ matrix.sdk_version_prefix }}${!sdk_version_env}" >> "$GITHUB_ENV" echo "PROJECT_IMAGE_TAG=omes-project-${{ matrix.sdk }}-${{ matrix.project }}" >> "$GITHUB_ENV" echo "PROJECT_WORKER_CONTAINER=omes-project-${{ matrix.sdk }}-${{ matrix.project }}-worker" >> "$GITHUB_ENV" echo "PROJECT_RUN_ID=project-${{ github.run_id }}-${{ matrix.sdk }}-${{ matrix.project }}" >> "$GITHUB_ENV" diff --git a/README.md b/README.md index 390efedc..07af3403 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ As such, it's a **good fit** if you: - are more familiar with Temporal-native code than Omes's framework and are not restricted by the **current limitations**: -- Python is the only implemented project language right now +- Python and TypeScript are the implemented project languages right now - the load pattern is limited to a steady-rate executor (i.e. "run 'x' times or run for 'y' duration), more nuanced load patterns will need to create their own scenario + executor (the existing method) @@ -310,6 +310,16 @@ docker stop omes-python-project-worker docker network rm omes-project-net ``` +The TypeScript project image uses the same project arguments with `dockerfiles/typescript.Dockerfile`: + +```sh +docker build \ + -f dockerfiles/typescript.Dockerfile \ + --build-arg PROJECT_NAME=helloworld \ + --build-arg SDK_VERSION=1.15.0 \ + -t omes-typescript-project-helloworld . +``` + This docker workflow it is not yet wired into `go run ./cmd/dev build-worker-image`. ### ThroughputStress diff --git a/dockerfiles/typescript.Dockerfile b/dockerfiles/typescript.Dockerfile index f792ee4b..e9adb1c9 100644 --- a/dockerfiles/typescript.Dockerfile +++ b/dockerfiles/typescript.Dockerfile @@ -33,6 +33,7 @@ COPY go.mod go.sum ./ RUN CGO_ENABLED=0 /usr/local/go/bin/go build -o temporal-omes ./cmd ARG SDK_VERSION +ARG PROJECT_NAME="" # Optional SDK dir to copy, defaults to unimportant file ARG SDK_DIR=.gitignore @@ -52,17 +53,27 @@ COPY workers/typescript ./workers/typescript RUN npm install -g pnpm # prepare-worker builds the TypeScript workspace itself: it installs npm deps, -# runs the root build, and generates the prepared sdkbuild package. -RUN CGO_ENABLED=0 ./temporal-omes prepare-worker --language ts --dir-name prepared --version "$SDK_VERSION" +# runs the relevant build, and generates the prepared sdkbuild package. +RUN if [ -n "$PROJECT_NAME" ]; then \ + CGO_ENABLED=0 ./temporal-omes prepare-worker --language ts --project-name "$PROJECT_NAME" --dir-name "project-build-runner-$PROJECT_NAME" --version "$SDK_VERSION" ; \ + else \ + CGO_ENABLED=0 ./temporal-omes prepare-worker --language ts --dir-name prepared --version "$SDK_VERSION" ; \ + fi # Copy the CLI and prepared feature to a "run" container. -# hadolint ignore=DL3006 -FROM --platform=linux/$TARGETARCH gcr.io/distroless/nodejs20-debian11 +FROM --platform=linux/$TARGETARCH node:20-bullseye-slim + +ARG PROJECT_NAME="" COPY --from=build /app/temporal-omes /app/temporal-omes COPY --from=build /app/workers/typescript /app/workers/typescript +COPY dockerfiles/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENV OMES_WORKER_LANGUAGE=typescript +ENV OMES_PREPARED_DIR=prepared +ENV OMES_PROJECT_NAME=$PROJECT_NAME +ENV OMES_PROJECT_PREPARED_DIR=project-build-runner-${PROJECT_NAME} +ENV OMES_PROJECT_PREBUILT_DIR=/app/workers/typescript/projects/tests/${PROJECT_NAME}/project-build-runner-${PROJECT_NAME} -# Node is installed here 👇 in distroless -ENV PATH="/nodejs/bin:$PATH" -# Use entrypoint instead of command to "bake" the default command options -ENTRYPOINT ["/app/temporal-omes", "run-worker", "--language", "typescript", "--dir-name", "prepared"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/scenarios/project/build.go b/scenarios/project/build.go index c980cc16..1c1ad8e7 100644 --- a/scenarios/project/build.go +++ b/scenarios/project/build.go @@ -21,7 +21,7 @@ func buildProject(ctx context.Context, repoRoot string, p projectScenarioOptions baseDir := workers.BaseDir(repoRoot, p.sdkOpts.Language) switch p.sdkOpts.Language { - case clioptions.LangPython: + case clioptions.LangPython, clioptions.LangTypeScript: return b.Build(ctx, baseDir) default: return nil, fmt.Errorf("unsupported language for project builds: %s", b.SdkOptions.Language) @@ -33,6 +33,8 @@ func loadPrebuilt(dir string, lang clioptions.Language) (sdkbuild.Program, error switch lang { case clioptions.LangPython: return sdkbuild.PythonProgramFromDir(dir) + case clioptions.LangTypeScript: + return sdkbuild.TypeScriptProgramFromDir(dir) default: return nil, fmt.Errorf("prebuilt projects not supported for language: %s", lang) } diff --git a/scenarios/project/handle.go b/scenarios/project/handle.go index 490be026..33c8be4e 100644 --- a/scenarios/project/handle.go +++ b/scenarios/project/handle.go @@ -26,11 +26,14 @@ type ProjectHandle struct { client api.ProjectServiceClient } -func newProjectHandle(ctx context.Context, port int, req *api.InitRequest) (ProjectHandle, error) { +func newProjectHandle(ctx context.Context, port int, req *api.InitRequest, readyTimeout time.Duration) (ProjectHandle, error) { address := fmt.Sprintf("%s:%d", defaultClientHost, port) c := ProjectHandle{address: address, taskQueue: req.GetTaskQueue()} - deadline := time.Now().Add(defaultClientReadyTimeout) + if readyTimeout == 0 { + readyTimeout = defaultClientReadyTimeout + } + deadline := time.Now().Add(readyTimeout) var err error for time.Now().Before(deadline) { conn, dialErr := net.Dial("tcp", c.address) @@ -47,7 +50,7 @@ func newProjectHandle(ctx context.Context, port int, req *api.InitRequest) (Proj } } if err != nil { - return ProjectHandle{}, fmt.Errorf("project server not ready after %v: %w", defaultClientReadyTimeout, err) + return ProjectHandle{}, fmt.Errorf("project server not ready after %v: %w", readyTimeout, err) } conn, err := grpc.NewClient( diff --git a/scenarios/project/project.go b/scenarios/project/project.go index 23015292..bee2e25a 100644 --- a/scenarios/project/project.go +++ b/scenarios/project/project.go @@ -39,7 +39,9 @@ type projectScenarioOptions struct { configJSON []byte } -type projectScenarioExecutor struct{} +type projectScenarioExecutor struct { + clientReadyTimeout time.Duration +} func (e *projectScenarioExecutor) Run(ctx context.Context, info loadgen.ScenarioInfo) error { opts, err := e.validate(info) @@ -91,7 +93,7 @@ func (e *projectScenarioExecutor) Run(ctx context.Context, info loadgen.Scenario DisableHostVerification: co.DisableHostVerification, }, ConfigJson: opts.configJSON, - }) + }, e.clientReadyTimeout) if err != nil { return fmt.Errorf("failed to init project: %w", err) } @@ -111,8 +113,8 @@ func (e *projectScenarioExecutor) validate(info loadgen.ScenarioInfo) (projectSc if err := opts.sdkOpts.Language.Set(lang); err != nil { return opts, fmt.Errorf("unrecognized language: %s", lang) } - if opts.sdkOpts.Language != clioptions.LangPython { - return opts, fmt.Errorf("project scenario is currently limited to Python, got %s", lang) + if opts.sdkOpts.Language != clioptions.LangPython && opts.sdkOpts.Language != clioptions.LangTypeScript { + return opts, fmt.Errorf("project scenario is currently limited to Python and TypeScript, got %s", lang) } projectName := info.ScenarioOptions["project-name"] @@ -161,9 +163,13 @@ func findAvailablePort() (int, error) { func startProjectProcess(ctx context.Context, prog sdkbuild.Program, logger *zap.SugaredLogger, lang clioptions.Language, port int) (*exec.Cmd, error) { var args []string - // Python needs module name - if lang == clioptions.LangPython { + switch lang { + case clioptions.LangPython: + // Python needs module name. args = append(args, "main") + case clioptions.LangTypeScript: + // Node needs the compiled module before the harness subcommand. + args = append(args, "./tslib/main.js") } args = append(args, "project-server", "--port", strconv.Itoa(port)) cmd, err := prog.NewCommand(ctx, args...) diff --git a/scenarios/project/project_test.go b/scenarios/project/project_test.go index bd48aefe..75047681 100644 --- a/scenarios/project/project_test.go +++ b/scenarios/project/project_test.go @@ -27,13 +27,13 @@ func TestValidateRequiresLanguage(t *testing.T) { require.EqualError(t, err, "--option language= is required") } -func TestValidateLimitedPythonSupport(t *testing.T) { +func TestValidateLimitedProjectLanguageSupport(t *testing.T) { _, err := (&projectScenarioExecutor{}).validate(loadgen.ScenarioInfo{ ScenarioOptions: map[string]string{ "language": "go", }, }) - require.EqualError(t, err, "project scenario is currently limited to Python, got go") + require.EqualError(t, err, "project scenario is currently limited to Python and TypeScript, got go") } func TestValidateRejectsConflictingProjectSources(t *testing.T) { @@ -55,6 +55,14 @@ func TestPythonHelloWorldPrebuilt(t *testing.T) { runProjectScenario(t, "python", "helloworld", "", nil, true) } +func TestTypeScriptHelloWorldSourceBuild(t *testing.T) { + runProjectScenario(t, "typescript", "helloworld", "", nil, false) +} + +func TestTypeScriptHelloWorldPrebuilt(t *testing.T) { + runProjectScenario(t, "typescript", "helloworld", "", nil, true) +} + func runProjectScenario( t *testing.T, lang, projectName, version string, @@ -88,7 +96,9 @@ func runProjectScenario( scenarioErrCh := make(chan error, 1) go func() { - scenarioErrCh <- (&projectScenarioExecutor{}).Run(ctx, info) + scenarioErrCh <- (&projectScenarioExecutor{ + clientReadyTimeout: 30 * time.Second, + }).Run(ctx, info) }() select { diff --git a/workers/build.go b/workers/build.go index f90c4fa6..d6c8e5a7 100644 --- a/workers/build.go +++ b/workers/build.go @@ -149,6 +149,16 @@ func (b *Builder) buildJava(ctx context.Context, baseDir string) (sdkbuild.Progr } func (b *Builder) buildTypeScript(ctx context.Context, baseDir string) (sdkbuild.Program, error) { + moreDependencies := map[string]string{ + "@temporalio/omes-project-harness": "file:../harness", + "winston": "^3.11.0", + } + if b.ProjectName != "" { + // Project fixtures build from workers/typescript/projects/tests/, + // so their generated package needs to walk back to workers/typescript/harness. + moreDependencies["@temporalio/omes-project-harness"] = "file:../../../../harness" + } + // If version not provided, try to read it from package.json version := b.SdkOptions.Version if version == "" { @@ -190,18 +200,15 @@ func (b *Builder) buildTypeScript(ctx context.Context, baseDir string) (sdkbuild } prog, err := sdkbuild.BuildTypeScriptProgram(ctx, sdkbuild.BuildTypeScriptProgramOptions{ - BaseDir: baseDir, - Version: version, - TSConfigPaths: map[string][]string{"@temporalio/omes": {"src/omes.ts"}}, - DirName: b.DirName, - ApplyToCommand: nil, - Includes: []string{"../src/**/*.ts", "../src/protos/json-module.js", "../src/protos/root.js"}, - MoreDependencies: map[string]string{ - "@temporalio/omes-project-harness": "file:../harness", - "winston": "^3.11.0", - }, - Stdout: b.stdout, - Stderr: b.stderr, + BaseDir: baseDir, + Version: version, + TSConfigPaths: map[string][]string{"@temporalio/omes": {"src/main.ts"}}, + DirName: b.DirName, + ApplyToCommand: nil, + Includes: []string{"../src/**/*.ts", "../src/protos/json-module.js", "../src/protos/root.js"}, + MoreDependencies: moreDependencies, + Stdout: b.stdout, + Stderr: b.stderr, }) if err != nil { return nil, fmt.Errorf("failed preparing: %w", err) diff --git a/workers/run.go b/workers/run.go index 9d2da136..8d2b0290 100644 --- a/workers/run.go +++ b/workers/run.go @@ -129,7 +129,7 @@ func (r *Runner) Run(ctx context.Context, baseDir string) error { args = append(args, "main", "worker") case clioptions.LangTypeScript: // Node also needs module before the harness subcommand. - args = append(args, "./tslib/omes.js", "worker") + args = append(args, "./tslib/main.js", "worker") case clioptions.LangDotNet, clioptions.LangRuby, clioptions.LangGo: // .NET, Ruby, and Go just need the harness worker subcommand args = append(args, "worker") diff --git a/workers/typescript/package-lock.json b/workers/typescript/package-lock.json index 9b0ab555..f44e685b 100644 --- a/workers/typescript/package-lock.json +++ b/workers/typescript/package-lock.json @@ -1328,6 +1328,10 @@ "resolved": "harness", "link": true }, + "node_modules/@temporalio/omes-project-helloworld": { + "resolved": "projects/tests/helloworld", + "link": true + }, "node_modules/@temporalio/proto": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@temporalio/proto/-/proto-1.15.0.tgz", @@ -5209,6 +5213,20 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "projects/tests/helloworld": { + "name": "@temporalio/omes-project-helloworld", + "version": "0.1.0", + "dependencies": { + "@temporalio/client": "1.15.0", + "@temporalio/omes-project-harness": "file:../../../harness", + "@temporalio/worker": "1.15.0" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.1.0", + "typescript": "^5.9.3" + } } } } diff --git a/workers/typescript/projects/tests/helloworld/package.json b/workers/typescript/projects/tests/helloworld/package.json new file mode 100644 index 00000000..84e6c3f2 --- /dev/null +++ b/workers/typescript/projects/tests/helloworld/package.json @@ -0,0 +1,21 @@ +{ + "name": "@temporalio/omes-project-helloworld", + "version": "0.1.0", + "private": true, + "main": "./dist/main.js", + "types": "./dist/main.d.ts", + "scripts": { + "build": "tsc --build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@temporalio/client": "1.15.0", + "@temporalio/omes-project-harness": "file:../../../harness", + "@temporalio/worker": "1.15.0" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.1.0", + "typescript": "^5.9.3" + } +} diff --git a/workers/typescript/projects/tests/helloworld/src/app.ts b/workers/typescript/projects/tests/helloworld/src/app.ts new file mode 100644 index 00000000..6fe2f8b7 --- /dev/null +++ b/workers/typescript/projects/tests/helloworld/src/app.ts @@ -0,0 +1,46 @@ +import type { Client } from '@temporalio/client'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import { + defaultClientFactory, + type App, + type ProjectExecuteContext, + type WorkerContext, +} from '@temporalio/omes-project-harness'; + +export function app(): App { + return { + worker: buildWorker, + clientFactory: defaultClientFactory, + project: { + execute: executeProjectIteration, + }, + }; +} + +async function buildWorker(client: Client, context: WorkerContext): Promise { + const connection = client.connection; + if (!(connection instanceof NativeConnection)) { + throw new Error('Helloworld project requires a NativeConnection-backed client'); + } + + return await Worker.create({ + connection, + namespace: client.options.namespace, + taskQueue: context.taskQueue, + workflowsPath: require.resolve('./workflow'), + ...context.workerOptions, + }); +} + +async function executeProjectIteration( + client: Client, + context: ProjectExecuteContext, +): Promise { + const handle = await client.workflow.start('helloWorldWorkflow', { + args: ['World'], + taskQueue: context.taskQueue, + workflowId: `${context.run.executionId}-${context.iteration}`, + }); + const result = await handle.result(); + console.log(result); +} diff --git a/workers/typescript/projects/tests/helloworld/src/main.ts b/workers/typescript/projects/tests/helloworld/src/main.ts new file mode 100644 index 00000000..233a8302 --- /dev/null +++ b/workers/typescript/projects/tests/helloworld/src/main.ts @@ -0,0 +1,11 @@ +import { run } from '@temporalio/omes-project-harness'; +import { app } from './app'; + +async function main(): Promise { + await run(app()); +} + +void main().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); diff --git a/workers/typescript/projects/tests/helloworld/src/workflow.ts b/workers/typescript/projects/tests/helloworld/src/workflow.ts new file mode 100644 index 00000000..8e873ddf --- /dev/null +++ b/workers/typescript/projects/tests/helloworld/src/workflow.ts @@ -0,0 +1,3 @@ +export async function helloWorldWorkflow(name: string): Promise { + return `Hello ${name}`; +} diff --git a/workers/typescript/projects/tests/helloworld/tsconfig.json b/workers/typescript/projects/tests/helloworld/tsconfig.json new file mode 100644 index 00000000..1824b1ac --- /dev/null +++ b/workers/typescript/projects/tests/helloworld/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "incremental": true, + "rootDir": "./src", + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "omes-temp-*", "project-build-runner-*"] +} diff --git a/workers/typescript/src/omes.ts b/workers/typescript/src/main.ts similarity index 100% rename from workers/typescript/src/omes.ts rename to workers/typescript/src/main.ts