diff --git a/.github/workflows/all-docker-images.yaml b/.github/workflows/all-docker-images.yaml index 2bd34804..38b8ce4e 100644 --- a/.github/workflows/all-docker-images.yaml +++ b/.github/workflows/all-docker-images.yaml @@ -14,7 +14,7 @@ on: description: Python SDK ver to build. Skipped if not specified. Must start with v. type: string php-ver: - description: PHP SDK ver to build. Skipped if not specified. + description: PHP SDK ver to build. Skipped if not specified. Must start with v. type: string ts-ver: description: TypeScript SDK ver to build. Skipped if not specified. Must start with v. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a154ce43..c5ed5ea1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,9 @@ on: # rebuild any PRs and main branch changes java_sdk_version: default: '' type: string + php_sdk_version: + default: '' + type: string python_sdk_version: default: '' type: string @@ -39,6 +42,7 @@ jobs: go_latest: ${{ steps.latest_version.outputs.go_latest }} typescript_latest: ${{ steps.latest_version.outputs.typescript_latest }} java_latest: ${{ steps.latest_version.outputs.java_latest }} + php_latest: ${{ steps.latest_version.outputs.php_latest }} python_latest: ${{ steps.latest_version.outputs.python_latest }} csharp_latest: ${{ steps.latest_version.outputs.csharp_latest }} steps: @@ -75,6 +79,13 @@ jobs: fi echo "java_latest=$java_latest" >> $GITHUB_OUTPUT + php_latest="${{ github.event.inputs.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 }}" if [ -z "$python_latest" ]; then python_latest=$(./temporal-features latest-sdk-version --lang py) @@ -209,6 +220,6 @@ jobs: go-ver: 'v${{ needs.build-go.outputs.go_latest }}' ts-ver: 'v${{ needs.build-go.outputs.typescript_latest }}' java-ver: 'v${{ needs.build-go.outputs.java_latest }}' - php-ver: '${{ needs.build-go.outputs.php_latest }}' + 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 }}' diff --git a/cmd/prepare.go b/cmd/prepare.go index 08138d42..d08f204c 100644 --- a/cmd/prepare.go +++ b/cmd/prepare.go @@ -90,6 +90,8 @@ func (p *Preparer) Prepare(ctx context.Context) error { _, err = p.BuildJavaProgram(ctx, true) case "ts": _, err = p.BuildTypeScriptProgram(ctx) + case "php": + _, err = p.BuildPhpProgram(ctx) case "py": _, err = p.BuildPythonProgram(ctx) case "cs": diff --git a/cmd/run.go b/cmd/run.go index 3ac022be..aae6c9e1 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -295,6 +295,16 @@ func (r *Runner) Run(ctx context.Context, patterns []string) error { if err == nil { err = r.RunTypeScriptExternal(ctx, run) } + case "php": + if r.config.DirName != "" { + r.program, err = sdkbuild.PhpProgramFromDir( + filepath.Join(r.rootDir, r.config.DirName), + r.rootDir, + ) + } + if err == nil { + err = r.RunPhpExternal(ctx, run) + } case "py": if r.config.DirName != "" { r.program, err = sdkbuild.PythonProgramFromDir(filepath.Join(r.rootDir, r.config.DirName)) @@ -545,7 +555,7 @@ func (r *Runner) destroyTempDir() { func normalizeLangName(lang string) (string, error) { // Normalize to file extension switch lang { - case "go", "java", "ts", "py", "cs": + case "go", "java", "ts", "php", "py", "cs": case "typescript": lang = "ts" case "python": @@ -561,7 +571,7 @@ func normalizeLangName(lang string) (string, error) { func expandLangName(lang string) (string, error) { // Expand to lang name switch lang { - case "go", "java", "typescript", "python": + case "go", "java", "php", "typescript", "python": case "ts": lang = "typescript" case "py": diff --git a/cmd/run_php.go b/cmd/run_php.go new file mode 100644 index 00000000..e906e307 --- /dev/null +++ b/cmd/run_php.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "fmt" + "path/filepath" + "github.com/temporalio/features/harness/go/cmd" + "github.com/temporalio/features/sdkbuild" +) + +// PreparePhpExternal prepares a PHP 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) BuildPhpProgram(ctx context.Context) (sdkbuild.Program, error) { + p.log.Info("Building PHP project", "DirName", p.config.DirName) + + prog, err := sdkbuild.BuildPhpProgram(ctx, sdkbuild.BuildPhpProgramOptions{ + DirName: p.config.DirName, + Version: p.config.Version, + RootDir: p.rootDir, + }) + if err != nil { + p.log.Error("failed preparing: %w", err) + return nil, fmt.Errorf("failed preparing: %w", err) + } + return prog, nil +} + +// RunPhpExternal runs the PHP run in an external process. This expects +// the server to already be started. +func (r *Runner) RunPhpExternal(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).BuildPhpProgram(ctx); err != nil { + return err + } + } + + // Compose RoadRunner command options + args := append( + []string{ + // Namespace + "namespace=" + r.config.Namespace, + // Server address + "address=" + r.config.Server, + }, + // Features + run.ToArgs()..., + ) + // TLS + if r.config.ClientCertPath != "" { + clientCertPath, err := filepath.Abs(r.config.ClientCertPath) + if err != nil { + return err + } + args = append(args, "tls.cert="+clientCertPath) + } + if r.config.ClientKeyPath != "" { + clientKeyPath, err := filepath.Abs(r.config.ClientKeyPath) + if err != nil { + return err + } + args = append(args, "tls.key="+clientKeyPath) + } + + // Run + cmd, err := r.program.NewCommand(ctx, args...) + if err == nil { + // r.log.Debug("Running PHP separately", "Args", cmd.Args) + err = cmd.Run() + } + if err != nil { + return fmt.Errorf("failed running: %w", err) + } + return nil +} diff --git a/harness/php/.gitignore b/harness/php/.gitignore new file mode 100644 index 00000000..d069ae6b --- /dev/null +++ b/harness/php/.gitignore @@ -0,0 +1,5 @@ +# PHP stuff +vendor +rr +rr.exe +composer.lock diff --git a/harness/php/.rr.yaml b/harness/php/.rr.yaml new file mode 100644 index 00000000..a5e77a4a --- /dev/null +++ b/harness/php/.rr.yaml @@ -0,0 +1,22 @@ +version: "3" +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: "php worker.php" + +# Workflow and activity mesh service +temporal: + address: ${TEMPORAL_ADDRESS:-localhost:7233} + namespace: ${TEMPORAL_NAMESPACE:-default} + activities: + num_workers: 2 + +kv: + harness: + driver: memory + config: { } + +logs: + mode: development + level: info diff --git a/harness/php/composer.json b/harness/php/composer.json new file mode 100644 index 00000000..ef6c04c5 --- /dev/null +++ b/harness/php/composer.json @@ -0,0 +1,24 @@ +{ + "name": "temporal/harness", + "type": "project", + "description": "Temporal SDK Harness", + "keywords": ["temporal", "sdk", "harness"], + "license": "MIT", + "require": { + "buggregator/trap": "^1.9", + "spiral/core": "^3.13", + "symfony/process": ">=6.4", + "temporal/sdk": "^2.11.0", + "webmozart/assert": "^1.11" + }, + "autoload": { + "psr-4": { + "Harness\\": "src/" + } + }, + "scripts": { + "rr-get": "rr get" + }, + "prefer-stable": true, + "minimum-stability": "dev" +} diff --git a/sdkbuild/php.go b/sdkbuild/php.go new file mode 100644 index 00000000..2b9c9fc7 --- /dev/null +++ b/sdkbuild/php.go @@ -0,0 +1,137 @@ +package sdkbuild + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// BuildPhpProgramOptions are options for BuildPhpProgram. +type BuildPhpProgramOptions struct { + // Required version. If it contains a slash it is assumed to be a path with + // a single wheel in the dist directory. Otherwise it is a specific version + // (with leading "v" is trimmed if present). + Version string + // If present, this directory is expected to exist beneath base dir. Otherwise + // a temporary dir is created. + DirName string + RootDir string +} + +// PhpProgram is a PHP-specific implementation of Program. +type PhpProgram struct { + dir string + source string +} + +var _ Program = (*PhpProgram)(nil) + +// BuildPhpProgram builds a PHP program. If completed successfully, this +// can be stored and re-obtained via PhpProgramFromDir() with the Dir() value +func BuildPhpProgram(ctx context.Context, options BuildPhpProgramOptions) (*PhpProgram, error) { + // Working directory + // Create temp dir if needed that we will remove if creating is unsuccessful + var dir string + if options.DirName != "" { + dir = filepath.Join(options.RootDir, options.DirName) + } else { + var err error + dir, err = os.MkdirTemp(options.RootDir, "program-") + if err != nil { + return nil, fmt.Errorf("failed making temp dir: %w", err) + } + } + + sourceDir := GetSourceDir(options.RootDir) + + // Skip if installed + if st, err := os.Stat(filepath.Join(dir, "vendor")); err == nil && st.IsDir() { + return &PhpProgram{dir, sourceDir}, nil + } + + // Copy composer.json from sourceDir into dir + data, err := os.ReadFile(filepath.Join(sourceDir, "composer.json")) + if err != nil { + return nil, fmt.Errorf("failed reading composer.json file: %w", err) + } + err = os.WriteFile(filepath.Join(dir, "composer.json"), data, 0755) + if err != nil { + return nil, fmt.Errorf("failed writing composer.json file: %w", err) + } + + // Copy .rr.yaml from sourceDir into dir + data, err = os.ReadFile(filepath.Join(sourceDir, ".rr.yaml")) + if err != nil { + return nil, fmt.Errorf("failed reading .rr.yaml file: %w", err) + } + err = os.WriteFile(filepath.Join(dir, ".rr.yaml"), data, 0755) + if err != nil { + return nil, fmt.Errorf("failed writing .rr.yaml file: %w", err) + } + + var cmd *exec.Cmd + // Setup required SDK version if specified + if options.Version != "" { + cmd = exec.CommandContext(ctx, "composer", "req", "temporal/sdk", options.Version, "-W", "--no-install") + 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 installing SDK deps: %w", err) + } + } + + // Install dependencies via composer + cmd = exec.CommandContext(ctx, "composer", "i", "-n", "-o", "-q", "--no-scripts", "--ignore-platform-reqs") + 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 installing SDK deps: %w", err) + } + + // Download RoadRunner + rrExe := filepath.Join(dir, "rr") + if runtime.GOOS == "windows" { + rrExe += ".exe" + } + _, err = os.Stat(rrExe) + if os.IsNotExist(err) { + cmd = exec.CommandContext(ctx, "composer", "run", "rr-get") + 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 downloading RoadRunner: %w", err) + } + } + + return &PhpProgram{dir, sourceDir}, nil +} + +// PhpProgramFromDir recreates the Php program from a Dir() result of a +// BuildPhpProgram(). Note, the base directory of dir when it was built must +// also be present. +func PhpProgramFromDir(dir string, rootDir string) (*PhpProgram, error) { + // Quick sanity check on the presence of package.json here + if _, err := os.Stat(filepath.Join(dir, "composer.json")); err != nil { + return nil, fmt.Errorf("failed finding composer.json in dir: %w", err) + } + return &PhpProgram{dir, GetSourceDir(rootDir)}, nil +} + +func GetSourceDir(rootDir string) string { + return filepath.Join(rootDir, "harness", "php") +} + +// Dir is the directory to run in. +func (p *PhpProgram) Dir() string { return p.dir } + +// NewCommand makes a new RoadRunner run command +func (p *PhpProgram) NewCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { + args = append([]string{filepath.Join(p.source, "runner.php")}, args...) + cmd := exec.CommandContext(ctx, "php", args...) + cmd.Dir = p.dir + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd, nil +}