From feba1c64380302adaeab7735d9480d6c438d2623 Mon Sep 17 00:00:00 2001 From: Rob Landers Date: Sat, 26 Jul 2025 22:56:19 +0200 Subject: [PATCH 01/51] Update build-cli.yaml Signed-off-by: Rob Landers --- .github/workflows/build-cli.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/build-cli.yaml b/.github/workflows/build-cli.yaml index 05cb2571..0cf69504 100644 --- a/.github/workflows/build-cli.yaml +++ b/.github/workflows/build-cli.yaml @@ -21,11 +21,6 @@ jobs: runs-on: self-hosted steps: - uses: actions/checkout@v4 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.HUB_USERNAME }} - password: ${{ secrets.HUB_PASSWORD }} - name: Configure QEMU uses: docker/setup-qemu-action@v3 - name: Configure docker @@ -53,11 +48,6 @@ jobs: image: ${{ steps.meta.outputs.tags }} steps: - uses: actions/checkout@v4 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.HUB_USERNAME }} - password: ${{ secrets.HUB_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: From 9f26eac1de4db8e56ed5895edbbb59e00b002083 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sat, 26 Jul 2025 23:05:50 +0200 Subject: [PATCH 02/51] fix deps Signed-off-by: Robert Landers --- cli/go.mod | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/go.mod b/cli/go.mod index af861831..43ba20cb 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,6 +1,7 @@ module durable_php -go 1.23 +go 1.24 + require github.com/dunglas/frankenphp v1.4.4 require github.com/nats-io/nats.go v1.38.0 From 1acdf354bed7b390112e07dd96208fd7c28e1ad6 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sat, 26 Jul 2025 23:32:00 +0200 Subject: [PATCH 03/51] update cli deps Signed-off-by: Robert Landers --- .idea/php.xml | 3 ++ Dockerfile | 4 +-- cli/Makefile | 34 ++++++++++---------- cli/cli.go | 2 +- cli/glue/glue.go | 2 +- cli/go.mod | 36 ++++++++++----------- cli/go.sum | 84 ++++++++++++++++++++++++++---------------------- cli/lib/api.go | 2 +- 8 files changed, 89 insertions(+), 78 deletions(-) diff --git a/.idea/php.xml b/.idea/php.xml index 0e79208f..02146469 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -6,6 +6,9 @@ + + diff --git a/Dockerfile b/Dockerfile index 57d6c506..70ea8cfd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -FROM golang:1.24.0-alpine AS golang-base -FROM php:8.4.3-zts AS php-base +FROM golang:1.24.5-alpine AS golang-base +FROM php:8.4.10-zts AS php-base FROM golang-base AS cli-base-alpine SHELL ["/bin/ash", "-eo", "pipefail", "-c"] diff --git a/cli/Makefile b/cli/Makefile index 3953e668..1b938225 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,17 +1,17 @@ -TARGET := dphp-linux-* -BIN_PATH := ../bin -DOCKER_IMAGE := builder -DOCKER_TARGET := cli-base-alpine -BUILD_PATH := /go/src/app/cli/dist - -${BIN_PATH}/${TARGET}: cli.go */* go.mod build.sh build-php.sh ../Dockerfile - mkdir -p ${BIN_PATH} - cd .. && docker buildx build --pull --load --target ${DOCKER_TARGET} -t ${DOCKER_IMAGE} . - docker create --name ${DOCKER_IMAGE} ${DOCKER_IMAGE} || ( docker rm -f ${DOCKER_IMAGE} && false ) - docker cp ${DOCKER_IMAGE}:${BUILD_PATH}/dphp ${BIN_PATH}/ || ( docker rm -f ${DOCKER_IMAGE} && false ) - docker rm -f ${DOCKER_IMAGE} - upx -9 --force-pie ../bin/dphp-* - -../dist: ${BIN_PATH}/${TARGET} - docker create --name builder builder - docker cp ${DOCKER_IMAGE}:${BUILD_PATH} ../dist +TARGET := dphp-linux-* +BIN_PATH := ../bin +DOCKER_IMAGE := builder +DOCKER_TARGET := cli-base-alpine +BUILD_PATH := /go/src/app/cli/dist + +${BIN_PATH}/${TARGET}: cli.go */* go.mod build.sh build-php.sh ../Dockerfile + mkdir -p ${BIN_PATH} + cd .. && docker buildx build --pull --load --target ${DOCKER_TARGET} -t ${DOCKER_IMAGE} . + docker create --name ${DOCKER_IMAGE} ${DOCKER_IMAGE} || ( docker rm -f ${DOCKER_IMAGE} && false ) + docker cp ${DOCKER_IMAGE}:${BUILD_PATH}/dphp ${BIN_PATH}/ || ( docker rm -f ${DOCKER_IMAGE} && false ) + docker rm -f ${DOCKER_IMAGE} + upx -9 --force-pie ../bin/dphp-* + +../dist: ${BIN_PATH}/${TARGET} + docker create --name builder builder + docker cp ${DOCKER_IMAGE}:${BUILD_PATH} ../dist diff --git a/cli/cli.go b/cli/cli.go index 67ed5d9e..32d19a44 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -201,7 +201,7 @@ func execute(args []string, options map[string]string) int { panic(err) } - opts := []frankenphp.Option{frankenphp.WithNumThreads(runtime.NumCPU() * 2), frankenphp.WithLogger(logger)} + opts := []frankenphp.Option{frankenphp.WithNumThreads(runtime.NumCPU() * 2)} if err := frankenphp.Init(opts...); err != nil { panic(err) diff --git a/cli/glue/glue.go b/cli/glue/glue.go index 9ba9e784..0ac1fc56 100644 --- a/cli/glue/glue.go +++ b/cli/glue/glue.go @@ -168,7 +168,7 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log Response: nil, } - r, err = frankenphp.NewRequestWithContext(r, frankenphp.WithRequestLogger(logger), frankenphp.WithRequestEnv(env)) + r, err = frankenphp.NewRequestWithContext(r, frankenphp.WithRequestEnv(env)) if err != nil { panic(err) } diff --git a/cli/go.mod b/cli/go.mod index 43ba20cb..eb4048f9 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,12 +1,12 @@ module durable_php -go 1.24 +go 1.24.5 -require github.com/dunglas/frankenphp v1.4.4 +require github.com/dunglas/frankenphp v1.9.0 -require github.com/nats-io/nats.go v1.38.0 +require github.com/nats-io/nats.go v1.43.0 -require github.com/nats-io/nats-server/v2 v2.10.24 +require github.com/nats-io/nats-server/v2 v2.11.6 require github.com/teris-io/cli v1.0.1 @@ -16,7 +16,7 @@ require github.com/gorilla/mux v1.8.1 require github.com/typesense/typesense-go v1.1.0 -require github.com/golang-jwt/jwt/v4 v4.5.1 +require github.com/golang-jwt/jwt/v4 v4.5.2 require ( github.com/google/uuid v1.6.0 @@ -30,26 +30,26 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dolthub/maphash v0.1.0 // indirect - github.com/gammazero/deque v1.0.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/gammazero/deque v1.1.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/maypok86/otter v1.2.4 // indirect github.com/minio/highwayhash v1.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/jwt/v2 v2.7.3 // indirect - github.com/nats-io/nkeys v0.4.9 // indirect + github.com/nats-io/jwt/v2 v2.7.4 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oapi-codegen/runtime v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.21.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect - golang.org/x/time v0.8.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 80c53abe..969eb804 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,4 +1,6 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0= +github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -11,14 +13,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= -github.com/dunglas/frankenphp v1.4.4 h1:NbAjn8XGwQQRXAENsyBB3dSv5c/364b43IyqkT/4Feg= -github.com/dunglas/frankenphp v1.4.4/go.mod h1:y6H/Vp29TDz1TeGmx4z2sEJ02PsY3rpCvNJN/DQRQ5s= -github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= -github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/dunglas/frankenphp v1.9.0 h1:tucI7uSZEmwGRGg7JxAf3wTwLrYs319mSc6fATG9z5I= +github.com/dunglas/frankenphp v1.9.0/go.mod h1:jpmWK5Nmi2LkpgL+Td0+LQWRcQ5jVOYsuT9f+L7ohDs= +github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo= +github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -26,12 +30,14 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI= github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= @@ -40,28 +46,28 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE= -github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4= -github.com/nats-io/nats-server/v2 v2.10.24 h1:KcqqQAD0ZZcG4yLxtvSFJY7CYKVYlnlWoAiVZ6i/IY4= -github.com/nats-io/nats-server/v2 v2.10.24/go.mod h1:olvKt8E5ZlnjyqBGbAXtxvSQKsPodISK5Eo/euIta4s= -github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA= -github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw= -github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= -github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE= +github.com/nats-io/jwt/v2 v2.7.4 h1:jXFuDDxs/GQjGDZGhNgH4tXzSUK6WQi2rsj4xmsNOtI= +github.com/nats-io/jwt/v2 v2.7.4/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.11.6 h1:4VXRjbTUFKEB+7UoaKL3F5Y83xC7MxPoIONOnGgpkHw= +github.com/nats-io/nats-server/v2 v2.11.6/go.mod h1:2xoztlcb4lDL5Blh1/BiukkKELXvKQ5Vy29FPVRBUYs= +github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug= +github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= -github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= @@ -83,19 +89,21 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/cli/lib/api.go b/cli/lib/api.go index fc87db3c..96fafcf5 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -101,7 +101,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po request, err := frankenphp.NewRequestWithContext(request, frankenphp.WithRequestEnv(map[string]string{ "LOG_LEVEL": "DEBUG", - }), frankenphp.WithRequestLogger(logger)) + })) if err != nil { logger.Error("Failed to serve request", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) From 764f373424bf53eb5e71879c363c34f662c4769e Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sat, 26 Jul 2025 23:46:43 +0200 Subject: [PATCH 04/51] fix deps Signed-off-by: Robert Landers --- .idea/codeception.xml | 12 ---- .idea/durable-php.iml | 6 +- .idea/php.xml | 6 +- .idea/phpspec.xml | 102 ------------------------------ composer.json | 4 +- src/Events/EventQueue.php | 11 ++-- src/Events/TaskFailed.php | 4 +- src/Glue/glue.php | 7 +- src/RemoteOrchestrationClient.php | 11 ++-- tests/ClientTestCli.php | 5 +- 10 files changed, 30 insertions(+), 138 deletions(-) delete mode 100644 .idea/codeception.xml diff --git a/.idea/codeception.xml b/.idea/codeception.xml deleted file mode 100644 index 3e191480..00000000 --- a/.idea/codeception.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/durable-php.iml b/.idea/durable-php.iml index 90a1c862..10bc8175 100644 --- a/.idea/durable-php.iml +++ b/.idea/durable-php.iml @@ -6,6 +6,8 @@ + + @@ -40,7 +42,6 @@ - @@ -109,6 +110,9 @@ + + + diff --git a/.idea/php.xml b/.idea/php.xml index 02146469..0c461130 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -132,10 +132,7 @@ - - - @@ -143,6 +140,9 @@ + + + diff --git a/.idea/phpspec.xml b/.idea/phpspec.xml index 234a9fdb..7a64ebae 100644 --- a/.idea/phpspec.xml +++ b/.idea/phpspec.xml @@ -5,108 +5,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/composer.json b/composer.json index 3ebee4a1..3247a649 100644 --- a/composer.json +++ b/composer.json @@ -33,11 +33,11 @@ "amphp/parallel": "^2.2.9", "crell/serde": "^1.2.0", "nesbot/carbon": ">2.0", - "php": ">=8.3", + "php": ">=8.4", "php-di/php-di": "^7.0.7", "ramsey/uuid": "^4.7.6", "webonyx/graphql-php": "^15.12.5", - "withinboredom/time": "^5.0.0", + "withinboredom/time": "^6.0.0", "nikic/php-parser": "^5.1" }, "require-dev": { diff --git a/src/Events/EventQueue.php b/src/Events/EventQueue.php index 8ab8d867..48cfd506 100644 --- a/src/Events/EventQueue.php +++ b/src/Events/EventQueue.php @@ -28,9 +28,8 @@ use DateTimeImmutable; use Revolt\EventLoop; use SplQueue; -use Withinboredom\Time\Seconds; -use Withinboredom\Time\Time; -use Withinboredom\Time\TimeUnit; +use Withinboredom\Time; +use Withinboredom\Time\Unit; use function Withinboredom\Time\Seconds; @@ -101,8 +100,8 @@ public function getNext(array $requeueKeys): Event|null public function enqueue(string $key, Event $event): void { $delay = $this->getDelay($event); - if ($delay->as(TimeUnit::Seconds) > 0) { - EventLoop::delay($delay->as(TimeUnit::Seconds), function () use ($key, $event): void { + if ($delay->as(Unit::Seconds) > 0) { + EventLoop::delay($delay->as(Unit::Seconds), function () use ($key, $event): void { $this->enqueue($key, $event); if ($this->cancellation !== null) { $this->cancellation?->cancel(); @@ -134,7 +133,7 @@ private function getDelay(Event $event): Time $event = $event->getInnerEvent(); } - return new Seconds(0); + return Seconds(0); } private function addKey(string $key): void diff --git a/src/Events/TaskFailed.php b/src/Events/TaskFailed.php index b22fd428..4b17413a 100644 --- a/src/Events/TaskFailed.php +++ b/src/Events/TaskFailed.php @@ -44,8 +44,8 @@ public function __construct( public static function forTask( string $scheduledId, string $reason, - string $details = null, - string $previous = null, + string|null $details = null, + string|null $previous = null, ): self { return new self(Uuid::uuid7(), $scheduledId, $reason, $details, $previous); } diff --git a/src/Glue/glue.php b/src/Glue/glue.php index ebc6f123..9e91d418 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -1,4 +1,5 @@ toStream()) . "\n"; + echo 'EVENT~!~' . mb_trim($event->toStream()) . "\n"; } private function startOrchestration(): void @@ -320,7 +321,7 @@ private function getPermissions(): void break; case $attribute->getName() === TimeToLive::class: /** @var TimeToLive $attribute */ $attribute = $attribute->newInstance(); - $permissions['ttl'] = $attribute->timeToLive()->as(TimeUnit::Nanoseconds); + $permissions['ttl'] = $attribute->timeToLive()->as(Unit::Nanoseconds); break; } } diff --git a/src/RemoteOrchestrationClient.php b/src/RemoteOrchestrationClient.php index 9be72cf0..1cba3967 100644 --- a/src/RemoteOrchestrationClient.php +++ b/src/RemoteOrchestrationClient.php @@ -1,4 +1,5 @@ apiHost = rtrim($this->apiHost, '/'); + $this->apiHost = mb_rtrim($this->apiHost, '/'); } #[Override] @@ -167,9 +168,9 @@ public function waitForCompletion(OrchestrationInstance $instance): void $name = rawurlencode($instance->instanceId); $id = rawurlencode($instance->executionId); $req = new Request("{$this->apiHost}/orchestration/{$name}/{$id}?wait=60"); - $req->setInactivityTimeout(Hours(1)->as(TimeUnit::Seconds)); - $req->setTcpConnectTimeout(Seconds(30)->as(TimeUnit::Seconds)); - $req->setTransferTimeout(Hours(1)->as(TimeUnit::Seconds)); + $req->setInactivityTimeout(Hours(1)->as(Unit::Seconds)); + $req->setTcpConnectTimeout(Seconds(30)->as(Unit::Seconds)); + $req->setTransferTimeout(Hours(1)->as(Unit::Seconds)); if ($this->userToken) { $req->setHeader('Authorization', 'Bearer ' . $this->userToken); } diff --git a/tests/ClientTestCli.php b/tests/ClientTestCli.php index 7d13521e..15c73142 100644 --- a/tests/ClientTestCli.php +++ b/tests/ClientTestCli.php @@ -1,4 +1,5 @@ toString(), ); $client->raiseEvent($orchestrationInstance, 'event', ['data']); -$client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(TimeUnit::Seconds))); +$client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(Unit::Seconds))); var_dump($client->getStatus($orchestrationInstance)); From bd0dbea67b8ad1956f18244a0ecff697e094a898 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 00:02:02 +0200 Subject: [PATCH 05/51] remove profiler Signed-off-by: Robert Landers --- cli/cli.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index 32d19a44..69c7d8e8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -44,7 +44,6 @@ import ( "os" "os/signal" "runtime" - "runtime/pprof" "strings" "sync" "syscall" @@ -91,14 +90,14 @@ func execute(args []string, options map[string]string) int { defer os.RemoveAll(data) - profile, err := os.CreateTemp("", "") - if err != nil { - panic(err) - } - err = pprof.StartCPUProfile(profile) - if err != nil { - panic(err) - } + //profile, err := os.CreateTemp("", "") + //if err != nil { + // panic(err) + //} + //err = pprof.StartCPUProfile(profile) + //if err != nil { + // panic(err) + //} go func() { sigs := make(chan os.Signal, 1) @@ -107,10 +106,10 @@ func execute(args []string, options map[string]string) int { <-sigs - pprof.StopCPUProfile() - profile.Close() + //pprof.StopCPUProfile() + //profile.Close() - logger.Warn("Profile output", zap.String("Filename", profile.Name())) + //logger.Warn("Profile output", zap.String("Filename", profile.Name())) os.RemoveAll(data) os.Exit(0) From 158415519b429393fff3674c7a24df525b2b1abf Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 00:17:33 +0200 Subject: [PATCH 06/51] escape name and id of entities Signed-off-by: Robert Landers --- cli/lib/api.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cli/lib/api.go b/cli/lib/api.go index 96fafcf5..585b3c47 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -19,6 +19,7 @@ import ( "io" "math/rand" "net/http" + "net/url" "os" "slices" "strconv" @@ -327,9 +328,24 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) + + // url decode the name + escapedName, err := url.QueryUnescape(vars["name"]) + if err != nil { + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + logger.Error("Failed to url decode name", zap.Error(err)) + return + } + escapedId, err := url.QueryUnescape(vars["id"]) + if err != nil { + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + logger.Error("Failed to url decode id", zap.Error(err)) + return + } + id := &glue.EntityId{ - Name: strings.TrimSpace(vars["name"]), - Id: strings.TrimSpace(vars["id"]), + Name: escapedName, + Id: escapedId, } ctx := getCorrelationId(ctx, &request.Header, nil) From 623d40635e45697c2fa0ff11379093828563e88d Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 01:07:28 +0200 Subject: [PATCH 07/51] Revert "escape name and id of entities" This reverts commit 158415519b429393fff3674c7a24df525b2b1abf. --- cli/lib/api.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/cli/lib/api.go b/cli/lib/api.go index 585b3c47..96fafcf5 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -19,7 +19,6 @@ import ( "io" "math/rand" "net/http" - "net/url" "os" "slices" "strconv" @@ -328,24 +327,9 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - - // url decode the name - escapedName, err := url.QueryUnescape(vars["name"]) - if err != nil { - http.Error(writer, "Internal Server Error", http.StatusInternalServerError) - logger.Error("Failed to url decode name", zap.Error(err)) - return - } - escapedId, err := url.QueryUnescape(vars["id"]) - if err != nil { - http.Error(writer, "Internal Server Error", http.StatusInternalServerError) - logger.Error("Failed to url decode id", zap.Error(err)) - return - } - id := &glue.EntityId{ - Name: escapedName, - Id: escapedId, + Name: strings.TrimSpace(vars["name"]), + Id: strings.TrimSpace(vars["id"]), } ctx := getCorrelationId(ctx, &request.Header, nil) From 63ee492e9f80468e36c5cf2714b2d3fc41580980 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 13:46:03 +0200 Subject: [PATCH 08/51] handle hooks Signed-off-by: Robert Landers --- .idea/php.xml | 2 +- cli/lib/api.go | 4 +- src/Proxy/ClientProxy.php | 69 +++++++++++++++------- src/Proxy/Generator.php | 76 +++++++++++++++++-------- src/Proxy/OrchestratorProxy.php | 46 ++++++++++----- src/Proxy/SpyProxy.php | 51 ++++++++++++----- tests/Feature/ClientProxyTest.php | 39 ++++++------- tests/Feature/OrchestratorProxyTest.php | 32 ++++------- 8 files changed, 200 insertions(+), 119 deletions(-) diff --git a/.idea/php.xml b/.idea/php.xml index 0c461130..c543d88a 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -211,7 +211,7 @@ - + diff --git a/cli/lib/api.go b/cli/lib/api.go index 96fafcf5..b6fd6399 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -560,7 +560,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logger.Debug("Got change!") status, err := extractStatus(update.Value()) if err != nil { - http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + http.Error(writer, "\"Internal Server Error\"", http.StatusInternalServerError) return } if runtimeStatus, ok := status.(map[string]interface{})["runtimeStatus"].(string); ok { @@ -620,7 +620,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po }) r.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { - logger.Warn("Unkown endpoint") + logger.Warn("Unknown endpoint") ctx := getCorrelationId(ctx, &request.Header, nil) logRequest(logger, request, ctx) }) diff --git a/src/Proxy/ClientProxy.php b/src/Proxy/ClientProxy.php index 8e6fcdfa..5cdc2f5c 100644 --- a/src/Proxy/ClientProxy.php +++ b/src/Proxy/ClientProxy.php @@ -1,4 +1,5 @@ getName(); + if ($isHook) { + $name = explode('::', $method->getName())[0]; + $name = str_replace('$', '', $name); + $name = ucfirst($name); + } else { + $name = $method->getName(); + } $params = $method->getParameters(); $params = array_map( - function (\ReflectionParameter $param) { + function (ReflectionParameter $param) { $type = $param->getType(); if ($type !== null) { $type = $this->getTypes($type); @@ -44,29 +55,41 @@ function (\ReflectionParameter $param) { $return = $method->getReturnType(); $return = $return ? ": {$this->getTypes($return)}" : ''; + if ($isHook) { + return <<source->__set{$name}(\$value); + } + EOT; + } + return <<source->{$name}(...func_get_args()); -} -EOT; + public function {$name}({$params}){$return} { + return \$this->source->{$name}(...func_get_args()); + } + EOT; } - protected function getName(\ReflectionClass $class): string + protected function getName(ReflectionClass $class): string { return "__ClientProxy_{$class->getShortName()}"; } - protected function impureSignal(\ReflectionMethod $method): string + protected function impureSignal(ReflectionMethod $method): string { return $this->impureCall($method); } - protected function impureCall(\ReflectionMethod $method): string + protected function impureCall(ReflectionMethod $method, bool $isHook = false): string { - $name = $method->getName(); + if ($isHook) { + $name = 'get'; + } else { + $name = $method->getName(); + } $params = $method->getParameters(); $params = array_map( - function (\ReflectionParameter $param) { + function (ReflectionParameter $param) { $type = $param->getType(); if ($type !== null) { $type = $this->getTypes($type); @@ -80,17 +103,25 @@ function (\ReflectionParameter $param) { $return = $method->getReturnType(); $return = $return ? ": {$this->getTypes($return)}" : ''; + if ($isHook) { + return <<getName($class = new \ReflectionClass($interface)); + $name = $this->getName($class = new ReflectionClass($interface)); $namespace = $this->getInterfaceNamespace($class); $cacheFile = null; if ($this->cacheDir) { @@ -42,7 +49,7 @@ public function define(string $interface): string } } - $reflection = new \ReflectionClass($interface); + $reflection = new ReflectionClass($interface); $fullname = $this->getInterfaceNamespace($reflection) . '\\' . $this->getName($reflection); if (!class_exists($fullname)) { @@ -55,21 +62,21 @@ public function define(string $interface): string return '\\' . $namespace . '\\' . $name; } - protected function getInterfaceNamespace(\ReflectionClass $class): string + abstract protected function getName(ReflectionClass $class): string; + + protected function getInterfaceNamespace(ReflectionClass $class): string { return $class->getNamespaceName(); } - abstract protected function getName(\ReflectionClass $class): string; - public function generate(string $interface): string { - $reflection = new \ReflectionClass($interface); + $reflection = new ReflectionClass($interface); $methods = $reflection->getMethods(); $namespace = $reflection->getNamespaceName(); $className = $reflection->getShortName(); $methods = array_map( - function (\ReflectionMethod $method) { + function (ReflectionMethod $method) { if ($method->getAttributes(Pure::class)) { return $this->pureMethod($method); } @@ -79,7 +86,7 @@ function (\ReflectionMethod $method) { } $return = $method->getReturnType(); - if (($return instanceof \ReflectionNamedType) && $return->getName() === 'void') { + if (($return instanceof ReflectionNamedType) && $return->getName() === 'void') { return $this->impureSignal($method); } @@ -89,29 +96,46 @@ function (\ReflectionMethod $method) { ); $methods = implode("\n", $methods); $namespace = $namespace ? "namespace {$namespace};" : ''; - + $props = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); + $props = array_map(function (ReflectionProperty $prop) { + $hooks = ['public ' . $this->getTypes($prop->getType()) . ' $' . $prop->getName() . '{']; + if ($hook = $prop->getHook(PropertyHookType::Get)) { + $hooks[] = $this->impureCall($hook, true); + } + if ($hook = $prop->getHook(PropertyHookType::Set)) { + $hooks[] = $this->pureMethod($hook, true); + } + $hooks[] = '}'; + return implode( + "\n", + array_filter( + $hooks, + fn($hook) => $hook !== '', + ), + ); + }, $props); + $props = implode("\n", $props); return <<getName($reflection)} implements {$className} { - {$this->preamble($reflection)} - {$methods} -} -EOT; + {$namespace} + + class {$this->getName($reflection)} implements {$className} { + {$this->preamble($reflection)} + {$props} + {$methods} + } + EOT; } - abstract protected function pureMethod(\ReflectionMethod $method): string; + abstract protected function pureMethod(ReflectionMethod $method, bool $isHook = false): string; - abstract protected function impureSignal(\ReflectionMethod $method): string; + abstract protected function impureSignal(ReflectionMethod $method): string; - abstract protected function impureCall(\ReflectionMethod $method): string; + abstract protected function impureCall(ReflectionMethod $method, bool $isHook = false): string; - abstract protected function preamble(\ReflectionClass $class): string; - - protected function getTypes(\ReflectionNamedType|ReflectionUnionType|\ReflectionIntersectionType|null $type): string + protected function getTypes(ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type): string { - if ($type instanceof \ReflectionNamedType) { - if($type->isBuiltin()) { + if ($type instanceof ReflectionNamedType) { + if ($type->isBuiltin()) { return $type->getName(); } return '\\' . $type->getName(); @@ -121,10 +145,12 @@ protected function getTypes(\ReflectionNamedType|ReflectionUnionType|\Reflection return implode('|', array_map($this->getTypes(...), $type->getTypes())); } - if ($type instanceof \ReflectionIntersectionType) { + if ($type instanceof ReflectionIntersectionType) { return implode('&', array_map($this->getTypes(...), $type->getTypes())); } return ''; } + + abstract protected function preamble(ReflectionClass $class): string; } diff --git a/src/Proxy/OrchestratorProxy.php b/src/Proxy/OrchestratorProxy.php index 026ee207..84ecd12a 100644 --- a/src/Proxy/OrchestratorProxy.php +++ b/src/Proxy/OrchestratorProxy.php @@ -1,4 +1,5 @@ impureCall($method); + return $this->impureCall($method, $isHook); } - protected function impureCall(ReflectionMethod $method): string + protected function impureCall(ReflectionMethod $method, bool $isHook = false): string { - $name = $method->getName(); + $getHook = 'return '; + if ($isHook && str_ends_with($method->getName(), 'get')) { + $name = 'get'; + } elseif ($isHook && str_ends_with($method->getName(), 'set')) { + $name = 'set'; + $getHook = ''; + } else { + $name = $method->getName(); + } $params = $method->getParameters(); $params = array_map( function (ReflectionParameter $param) { @@ -53,11 +62,20 @@ function (ReflectionParameter $param) { $return = $method->getReturnType(); $return = $return ? ": {$this->getTypes($return)}" : ''; + if ($isHook) { + $hookName = $method->getName(); + return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$hookName}", func_get_args())); + } + EOT; + } + return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$method->getName()}", func_get_args())); -} -EOT; + public function {$name}({$params}){$return} { + return \$this->context->waitOne(\$this->context->callEntity(\$this->id, "{$method->getName()}", func_get_args())); + } + EOT; } protected function getName(ReflectionClass $class): string @@ -85,16 +103,16 @@ function (ReflectionParameter $param) { $return = $return ? ": {$this->getTypes($return)}" : ''; return <<context->signalEntity(\$this->id, "{$method->getName()}", func_get_args()); -} -EOT; + public function {$name}({$params}){$return} { + \$this->context->signalEntity(\$this->id, "{$method->getName()}", func_get_args()); + } + EOT; } protected function preamble(ReflectionClass $class): string { return <<impureCall($method); } - protected function impureCall(\ReflectionMethod $method): string + protected function impureCall(ReflectionMethod $method, bool $isHook = false): string { - $name = $method->getName(); + $getHook = 'return '; + if ($isHook && str_ends_with($method->getName(), 'get')) { + $name = 'get'; + } elseif ($isHook && str_ends_with($method->getName(), 'set')) { + $name = 'set'; + $getHook = ''; + } else { + $name = $method->getName(); + } + $params = $method->getParameters(); $params = array_map( - function (\ReflectionParameter $param) { + function (ReflectionParameter $param) { $type = $param->getType(); if ($type !== null) { $type = $this->getTypes($type); @@ -49,26 +63,37 @@ function (\ReflectionParameter $param) { $return = $method->getReturnType(); $return = $return ? ": {$this->getTypes($return)}" : ''; + if ($isHook) { + $hookName = $method->getName(); + return <<operation = "{$hookName}"; + \$this->arguments = func_get_args(); + throw new \Exception('Not implemented'); + } + EOT; + } + return <<operation = "{$name}"; - \$this->arguments = func_get_args(); - throw new \Exception('Not implemented'); -} -EOT; + public function {$name}({$params}){$return} { + \$this->operation = "{$name}"; + \$this->arguments = func_get_args(); + throw new \Exception('Not implemented'); + } + EOT; } - protected function getName(\ReflectionClass $class): string + protected function getName(ReflectionClass $class): string { return "__SpyProxy_{$class->getShortName()}"; } - protected function impureSignal(\ReflectionMethod $method): string + protected function impureSignal(ReflectionMethod $method): string { return $this->impureCall($method); } - protected function preamble(\ReflectionClass $class): string + protected function preamble(ReflectionClass $class): string { return <<<'EOT' public function __construct(private string|null &$operation = null, private array|null &$arguments = null) {} diff --git a/tests/Feature/ClientProxyTest.php b/tests/Feature/ClientProxyTest.php index c76a05f3..200f011c 100644 --- a/tests/Feature/ClientProxyTest.php +++ b/tests/Feature/ClientProxyTest.php @@ -1,4 +1,5 @@ generate(orchProxy::class); - expect($proxy)->toBe( - <<<'EOT' - - -class __ClientProxy_orchProxy implements orchProxy { - public function __construct(private mixed $source) {} - public function callExample(): string { - throw new Bottledcode\DurablePhp\Proxy\ImpureException(); -} -public function signalExample(int $a): void { - throw new Bottledcode\DurablePhp\Proxy\ImpureException(); -} -public function pureExample(int|float $number): string { - return $this->source->pureExample(...func_get_args()); -} -} -EOT, - ); + expect($proxy)->toMatchSnapshot(); }); it('is actually callable', function (): void { $generator = new ClientProxy(); $proxy = $generator->generate(orchProxy::class); eval($proxy); - $instance = new class () { + $instance = new class { + public string $prop = 'test'; + public function pureExample(int|float $number): string { return "Hello {$number}"; } }; $proxy = new __ClientProxy_orchProxy($instance); - expect($proxy->pureExample(1))->toBe('Hello 1') - ->and(fn() => $proxy->signalExample(1))->toThrow(\Bottledcode\DurablePhp\Proxy\ImpureException::class) - ->and(fn() => $proxy->callExample())->toThrow(\Bottledcode\DurablePhp\Proxy\ImpureException::class); + expect($proxy->pureExample(1)) + ->toBe('Hello 1')->and(fn() => $proxy->signalExample(1))->toThrow( + ImpureException::class, + )->and(fn() => $proxy->callExample())->toThrow(ImpureException::class); }); diff --git a/tests/Feature/OrchestratorProxyTest.php b/tests/Feature/OrchestratorProxyTest.php index 18aa44e2..346bc85e 100644 --- a/tests/Feature/OrchestratorProxyTest.php +++ b/tests/Feature/OrchestratorProxyTest.php @@ -1,4 +1,5 @@ generate(orchProxy::class); - expect($proxy)->toBe( - <<<'EOT' - - -class __OrchestratorProxy_orchProxy implements orchProxy { - public function __construct(private \Bottledcode\DurablePhp\OrchestrationContextInterface $context, private \Bottledcode\DurablePhp\State\EntityId $id) {} - public function callExample(): string { - return $this->context->waitOne($this->context->callEntity($this->id, "callExample", func_get_args())); -} -public function signalExample(int $a): void { - $this->context->signalEntity($this->id, "signalExample", func_get_args()); -} -public function pureExample(int|float $number): string { - return $this->context->waitOne($this->context->callEntity($this->id, "pureExample", func_get_args())); -} -} -EOT, - ); + expect($proxy)->toMatchSnapshot(); }); it('actually works', function (): void { @@ -73,7 +62,6 @@ public function pureExample(int|float $number): string { $context->shouldReceive('signalEntity')->andReturn('signal'); $proxy = new __OrchestratorProxy_orchProxy($context, new EntityId('test', 'test')); - expect($proxy->callExample())->toBe('waited') - ->and($proxy->pureExample(1))->toBe('waited') - ->and($proxy->signalExample(1))->toBe(null); + expect($proxy->callExample()) + ->toBe('waited')->and($proxy->pureExample(1))->toBe('waited')->and($proxy->signalExample(1))->toBe(null); }); From eaaa1a07c728d4e42bdbbf003b47657f7b923bbb Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 16:29:20 +0200 Subject: [PATCH 09/51] fix value Signed-off-by: Robert Landers --- src/Proxy/OrchestratorProxy.php | 7 ++++++- src/Proxy/SpyProxy.php | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Proxy/OrchestratorProxy.php b/src/Proxy/OrchestratorProxy.php index 84ecd12a..da0e7169 100644 --- a/src/Proxy/OrchestratorProxy.php +++ b/src/Proxy/OrchestratorProxy.php @@ -64,9 +64,14 @@ function (ReflectionParameter $param) { if ($isHook) { $hookName = $method->getName(); + if ($getHook) { + $value = '[]'; + } else { + $value = '[$value]'; + } return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$hookName}", func_get_args())); + {$getHook}\$this->context->waitOne(\$this->context->callEntity(\$this->id, "{$hookName}", {$value})); } EOT; } diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 19e5fca9..3e1d7191 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -65,10 +65,15 @@ function (ReflectionParameter $param) { if ($isHook) { $hookName = $method->getName(); + if (str_ends_with($hookName, 'get')) { + $value = '[$value]'; + } else { + $value = '[]'; + } return <<operation = "{$hookName}"; - \$this->arguments = func_get_args(); + \$this->arguments = {$value}; throw new \Exception('Not implemented'); } EOT; @@ -96,7 +101,7 @@ protected function impureSignal(ReflectionMethod $method): string protected function preamble(ReflectionClass $class): string { return <<<'EOT' -public function __construct(private string|null &$operation = null, private array|null &$arguments = null) {} -EOT; + public function __construct(private string|null &$operation = null, private array|null &$arguments = null) {} + EOT; } } From 356ca4c0f455674b3222074fb52027e41e755e07 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 16:32:23 +0200 Subject: [PATCH 10/51] pass on whether or not it is a hook Signed-off-by: Robert Landers --- src/Proxy/SpyProxy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 3e1d7191..0772b9ff 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -32,7 +32,7 @@ class SpyProxy extends Generator { protected function pureMethod(ReflectionMethod $method, bool $isHook = false): string { - return $this->impureCall($method); + return $this->impureCall($method, $isHook); } protected function impureCall(ReflectionMethod $method, bool $isHook = false): string From be73d4734410fd4fe9cbc4fc7635bb53ddb8e67b Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 16:52:20 +0200 Subject: [PATCH 11/51] fix small issues Signed-off-by: Robert Landers --- src/Proxy/Generator.php | 2 +- src/Proxy/OrchestratorProxy.php | 1 + src/Proxy/SpyProxy.php | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Proxy/Generator.php b/src/Proxy/Generator.php index 6ddc3a00..80fe09a6 100644 --- a/src/Proxy/Generator.php +++ b/src/Proxy/Generator.php @@ -98,7 +98,7 @@ function (ReflectionMethod $method) { $namespace = $namespace ? "namespace {$namespace};" : ''; $props = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); $props = array_map(function (ReflectionProperty $prop) { - $hooks = ['public ' . $this->getTypes($prop->getType()) . ' $' . $prop->getName() . '{']; + $hooks = ['public ' . $this->getTypes($prop->getType()) . ' $' . $prop->getName() . ' {']; if ($hook = $prop->getHook(PropertyHookType::Get)) { $hooks[] = $this->impureCall($hook, true); } diff --git a/src/Proxy/OrchestratorProxy.php b/src/Proxy/OrchestratorProxy.php index da0e7169..dc8712de 100644 --- a/src/Proxy/OrchestratorProxy.php +++ b/src/Proxy/OrchestratorProxy.php @@ -69,6 +69,7 @@ function (ReflectionParameter $param) { } else { $value = '[$value]'; } + $hookName = str_replace('$', '\$', $hookName); return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$hookName}", {$value})); diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 0772b9ff..31c3ccdc 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -37,12 +37,12 @@ protected function pureMethod(ReflectionMethod $method, bool $isHook = false): s protected function impureCall(ReflectionMethod $method, bool $isHook = false): string { - $getHook = 'return '; + $getHook = true; if ($isHook && str_ends_with($method->getName(), 'get')) { $name = 'get'; } elseif ($isHook && str_ends_with($method->getName(), 'set')) { $name = 'set'; - $getHook = ''; + $getHook = false; } else { $name = $method->getName(); } @@ -70,6 +70,7 @@ function (ReflectionParameter $param) { } else { $value = '[]'; } + $hookName = str_replace('$', '\$', $hookName); return <<operation = "{$hookName}"; From c7d45088e6260fdbf037e6da9af45987d49144cb Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 17:05:04 +0200 Subject: [PATCH 12/51] update entity history to accept property hooks Signed-off-by: Robert Landers --- src/State/EntityHistory.php | 55 +++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/State/EntityHistory.php b/src/State/EntityHistory.php index 0a07121c..43c8afb2 100644 --- a/src/State/EntityHistory.php +++ b/src/State/EntityHistory.php @@ -68,8 +68,12 @@ class EntityHistory extends AbstractHistory private LockStateMachine $lockQueue; - public function __construct(public StateId $id, #[Field(exclude: true)] public ?DurableLogger $logger, private Provenance $user) - { + public function __construct( + public StateId $id, + #[Field(exclude: true)] + public ?DurableLogger $logger, + private Provenance $user, + ) { $this->entityId = $id->toEntityId(); } @@ -118,10 +122,12 @@ public function applyRaiseEvent(RaiseEvent $event, Event $original): Generator // reply to the lock request $reply = $this->getReplyTo($original); foreach ($reply as $nextEvent) { - yield WithPriority::high(With::id( - $nextEvent, - RaiseEvent::forLock('locked', $event->eventData['owner'], $event->eventData['target']), - )); + yield WithPriority::high( + With::id( + $nextEvent, + RaiseEvent::forLock('locked', $event->eventData['owner'], $event->eventData['target']), + ), + ); } break; case '__unlock': @@ -156,11 +162,19 @@ public function init(): void } $this->lockQueue ??= new LockStateMachine($this->id); - $this->state ??= new class () extends EntityState {}; + $this->state ??= new class extends EntityState {}; $this->name = $this->id->toEntityId()->name; $now = MonotonicClock::current()->now(); - $this->status = new Status($now, '', SerializedArray::fromArray([]), $this->id, $now, SerializedArray::fromArray([]), RuntimeStatus::Running); + $this->status = new Status( + $now, + '', + SerializedArray::fromArray([]), + $this->id, + $now, + SerializedArray::fromArray([]), + RuntimeStatus::Running, + ); $this->state = $this->container->get($this->name); } @@ -197,6 +211,16 @@ private function execute(Event $original, string $operation, array $input): Gene } } try { + if (str_contains($operation, '::')) { + [$property, $operation] = explode('::', $operation); + $result = match ($operation) { + 'get' => $this->state->{$property}, + 'set' => $this->state->{$property} = $input[0], + default => throw new ReflectionException('Unknown operation'), + }; + goto finalize; + } + $operationReflection = $reflector->getMethod($operation); } catch (ReflectionException) { // search attributes for matching operation @@ -239,9 +263,13 @@ private function execute(Event $original, string $operation, array $input): Gene } } + finalize: + if ($replyTo) { foreach ($replyTo as $reply) { - yield WithPriority::high(WithOrchestration::forInstance($reply, TaskCompleted::forId($original->eventId, $result ?? null))); + yield WithPriority::high( + WithOrchestration::forInstance($reply, TaskCompleted::forId($original->eventId, $result ?? null)), + ); } } } @@ -257,11 +285,10 @@ private function finalize(Event $event): Generator $now = time(); $cutoff = $now - 3600; // 1 hour $this->history[$event->eventId] = $this->debugHistory ? $event : $now; - $this->history = - array_filter( - $this->history, - static fn(int|bool|Event $value) => is_int($value) ? $value > $cutoff : $value, - ); + $this->history = array_filter( + $this->history, + static fn(int|bool|Event $value) => is_int($value) ? $value > $cutoff : $value, + ); $this->status = $this->status->with(lastUpdated: MonotonicClock::current()->now()); yield null; From 6eb1748328275e79138b0af108beef000e8d2af1 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 17:09:30 +0200 Subject: [PATCH 13/51] ensure name is set properly Signed-off-by: Robert Landers --- src/State/EntityHistory.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/State/EntityHistory.php b/src/State/EntityHistory.php index 43c8afb2..e4af7e32 100644 --- a/src/State/EntityHistory.php +++ b/src/State/EntityHistory.php @@ -213,6 +213,7 @@ private function execute(Event $original, string $operation, array $input): Gene try { if (str_contains($operation, '::')) { [$property, $operation] = explode('::', $operation); + $property = str_replace('$', '', $property); $result = match ($operation) { 'get' => $this->state->{$property}, 'set' => $this->state->{$property} = $input[0], From d9b425508f6d00705c247667d424bb583835a329 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 17:13:25 +0200 Subject: [PATCH 14/51] got set/get switched up Signed-off-by: Robert Landers --- src/Proxy/SpyProxy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 31c3ccdc..216f0394 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -65,7 +65,7 @@ function (ReflectionParameter $param) { if ($isHook) { $hookName = $method->getName(); - if (str_ends_with($hookName, 'get')) { + if (str_ends_with($hookName, 'set')) { $value = '[$value]'; } else { $value = '[]'; From 6b53c03af9c4f22d6dbc4e2d02ea315d1736b81d Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 17:14:36 +0200 Subject: [PATCH 15/51] add snapshots Signed-off-by: Robert Landers --- ...it_generates_a_client_proxy_correctly.snap | 22 +++++++++++++++++++ .../it_generates_a_proxy_correctly.snap | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap create mode 100644 tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap diff --git a/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap b/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap new file mode 100644 index 00000000..36921708 --- /dev/null +++ b/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap @@ -0,0 +1,22 @@ + + +class __ClientProxy_orchProxy implements orchProxy { + public function __construct(private mixed $source) {} + public string $prop { +get { + throw new Bottledcode\DurablePhp\Proxy\ImpureException(); +} +set { + $this->source->__setProp($value); +} +} + public function callExample(): string { + throw new Bottledcode\DurablePhp\Proxy\ImpureException(); +} +public function signalExample(int $a): void { + throw new Bottledcode\DurablePhp\Proxy\ImpureException(); +} +public function pureExample(int|float $number): string { + return $this->source->pureExample(...func_get_args()); +} +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap b/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap new file mode 100644 index 00000000..d49ac3ab --- /dev/null +++ b/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap @@ -0,0 +1,22 @@ + + +class __OrchestratorProxy_orchProxy implements orchProxy { + public function __construct(private \Bottledcode\DurablePhp\OrchestrationContextInterface $context, private \Bottledcode\DurablePhp\State\EntityId $id) {} + public string $prop { +get { + return $this->context->waitOne($this->context->callEntity($this->id, "\$prop::get", [])); +} +set { + $this->context->waitOne($this->context->callEntity($this->id, "\$prop::set", [$value])); +} +} + public function callExample(): string { + return $this->context->waitOne($this->context->callEntity($this->id, "callExample", func_get_args())); +} +public function signalExample(int $a): void { + $this->context->signalEntity($this->id, "signalExample", func_get_args()); +} +public function pureExample(int|float $number): string { + return $this->context->waitOne($this->context->callEntity($this->id, "pureExample", func_get_args())); +} +} \ No newline at end of file From d6528ede69b5b2de18f7adee809f85f9d9cf6319 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Sun, 27 Jul 2025 23:31:29 +0200 Subject: [PATCH 16/51] make ids easier to work with Signed-off-by: Robert Landers --- .idea/durable-php.iml | 1 + .idea/php.xml | 1 + composer.json | 3 ++- src/State/EntityId.php | 5 +++-- src/State/OrchestrationInstance.php | 7 +++++-- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.idea/durable-php.iml b/.idea/durable-php.iml index 10bc8175..89eeb102 100644 --- a/.idea/durable-php.iml +++ b/.idea/durable-php.iml @@ -113,6 +113,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index c543d88a..6be773b4 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -143,6 +143,7 @@ + diff --git a/composer.json b/composer.json index 3247a649..0552c7d6 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,8 @@ "ramsey/uuid": "^4.7.6", "webonyx/graphql-php": "^15.12.5", "withinboredom/time": "^6.0.0", - "nikic/php-parser": "^5.1" + "nikic/php-parser": "^5.1", + "withinboredom/record": "v0.1.0" }, "require-dev": { "laravel/pint": "^1.17.2", diff --git a/src/State/EntityId.php b/src/State/EntityId.php index e968df8d..ad238392 100644 --- a/src/State/EntityId.php +++ b/src/State/EntityId.php @@ -25,17 +25,18 @@ namespace Bottledcode\DurablePhp\State; use Stringable; +use Withinboredom\Record; /** * @template T */ -readonly class EntityId implements Stringable +class EntityId extends Record implements Stringable { /** * @param class-string $name * @param string $id */ - public function __construct(public string $name, public string $id) {} + public function __construct(protected(set) string $name, protected(set) string $id) {} public function __toString(): string { diff --git a/src/State/OrchestrationInstance.php b/src/State/OrchestrationInstance.php index 9e21bb9b..758534a6 100644 --- a/src/State/OrchestrationInstance.php +++ b/src/State/OrchestrationInstance.php @@ -24,9 +24,12 @@ namespace Bottledcode\DurablePhp\State; -readonly class OrchestrationInstance implements \Stringable +use Stringable; +use Withinboredom\Record; + +class OrchestrationInstance extends Record implements Stringable { - public function __construct(public string $instanceId, public string $executionId) {} + public function __construct(protected(set) string $instanceId, protected(set) string $executionId) {} public function __toString(): string { From 18e4742b8031522354d766131198f9420ede9ec1 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 10:48:31 +0200 Subject: [PATCH 17/51] convert entity and orchestration ids to records Signed-off-by: Robert Landers --- .idea/durable-php.iml | 4 +- .idea/php.xml | 2 +- cli/init/template/tests/integrationTest.php | 6 +- composer.json | 11 ++- docs/orchestrations.md | 2 +- src/EntityContext.php | 3 +- src/Gateway/Graph/index.php | 14 ++-- src/Glue/glue.php | 5 +- src/OrchestrationContext.php | 72 +++++++++------- src/RemoteEntityClient.php | 15 +++- src/State/EntityId.php | 19 ++++- src/State/Ids/StateId.php | 13 ++- src/State/OrchestrationInstance.php | 20 ++++- src/Testing/DummyOrchestrationContext.php | 45 ++++++---- src/functions.php | 25 ++++++ tests/Feature/OrchestratorProxyTest.php | 5 +- .../PerformanceTests/Bank/BankTransaction.php | 9 +- tests/PerformanceTests/PerformanceClient.php | 21 +++-- .../src/Benchmarks/Bank/BankTransaction.php | 7 +- tests/Pest.php | 20 +++-- tests/Unit/ActivityHistoryTest.php | 24 +++--- tests/Unit/EntityHistoryTest.php | 24 +++--- tests/Unit/LockIntegrationTest.php | 22 +++-- tests/Unit/OrchestrationHistoryTest.php | 83 ++++++++++++------- 24 files changed, 307 insertions(+), 164 deletions(-) create mode 100644 src/functions.php diff --git a/.idea/durable-php.iml b/.idea/durable-php.iml index 89eeb102..e72518c8 100644 --- a/.idea/durable-php.iml +++ b/.idea/durable-php.iml @@ -6,8 +6,6 @@ - - @@ -113,7 +111,7 @@ - + diff --git a/.idea/php.xml b/.idea/php.xml index 6be773b4..3afa7145 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -143,7 +143,7 @@ - + diff --git a/cli/init/template/tests/integrationTest.php b/cli/init/template/tests/integrationTest.php index d11056f8..346e36d9 100644 --- a/cli/init/template/tests/integrationTest.php +++ b/cli/init/template/tests/integrationTest.php @@ -6,18 +6,20 @@ use Bottledcode\DurablePhp\State\Serializer; use {{.Name}}\Entities\CountInterface; use {{.Name}}\Orchestrations\Password; +use function Bottledcode\DurablePhp\EntityId; +use function Bottledcode\DurablePhp\OrchestrationInstance; require_once __DIR__ . '/../vendor/autoload.php'; $client = DurableClient::get(); -$entity = new EntityId(CountInterface::class, random_int(0, 10000)); +$entity = EntityId(CountInterface::class, random_int(0, 10000)); echo "Signaling an entity, which will start an orchestration, which we will wait for completion\n"; $start = microtime(true); $client->signal($entity, fn(CountInterface $state) => $state->countTo(100)); -$client->waitForCompletion(new OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id)); +$client->waitForCompletion(OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id)); $time = number_format(microtime(true) - $start, 2); echo "Cool! That took $time seconds\n"; echo "Here's the state:\n" . json_encode($client->getEntitySnapshot($entity, CountInterface::class), JSON_PRETTY_PRINT) . "\n"; diff --git a/composer.json b/composer.json index 0552c7d6..2d1c66cb 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,10 @@ "autoload": { "psr-4": { "Bottledcode\\DurablePhp\\": "src/" - } + }, + "files": [ + "src/functions.php" + ] }, "autoload-dev": { "psr-4": { @@ -33,13 +36,13 @@ "amphp/parallel": "^2.2.9", "crell/serde": "^1.2.0", "nesbot/carbon": ">2.0", + "nikic/php-parser": "^5.1", "php": ">=8.4", "php-di/php-di": "^7.0.7", "ramsey/uuid": "^4.7.6", "webonyx/graphql-php": "^15.12.5", - "withinboredom/time": "^6.0.0", - "nikic/php-parser": "^5.1", - "withinboredom/record": "v0.1.0" + "withinboredom/records": "v0.1.2", + "withinboredom/time": "^6.0.0" }, "require-dev": { "laravel/pint": "^1.17.2", diff --git a/docs/orchestrations.md b/docs/orchestrations.md index d817827b..95c24a97 100644 --- a/docs/orchestrations.md +++ b/docs/orchestrations.md @@ -132,7 +132,7 @@ Here's an example showing a lock: ```php function orch(\Bottledcode\DurablePhp\OrchestrationContext $context): void { - $entityId = new \Bottledcode\DurablePhp\State\EntityId('myEntity', 'id'); + $entityId = \Bottledcode\DurablePhp\EntityId('myEntity', 'id'); $lock = $context->lockEntity($entityId); // from here-on, you have exclusive access to the entity $context->entityOp($entityId, fn($entity) => $entity->add(5)); diff --git a/src/EntityContext.php b/src/EntityContext.php index 49991dd2..fd55ba42 100644 --- a/src/EntityContext.php +++ b/src/EntityContext.php @@ -36,7 +36,6 @@ use Bottledcode\DurablePhp\State\EntityHistory; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\Ids\StateId; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Closure; use Crell\Serde\Attributes\ClassSettings; use DateTimeImmutable; @@ -132,7 +131,7 @@ public function startNewOrchestration(string $orchestration, array $input = [], $id = Uuid::uuid7()->toString(); } - $instance = StateId::fromInstance(new OrchestrationInstance($orchestration, $id)); + $instance = StateId::fromInstance(OrchestrationInstance($orchestration, $id)); $this->eventDispatcher->fire( WithOrchestration::forInstance( $instance, diff --git a/src/Gateway/Graph/index.php b/src/Gateway/Graph/index.php index 2c3f3dc7..102a4f81 100644 --- a/src/Gateway/Graph/index.php +++ b/src/Gateway/Graph/index.php @@ -1,4 +1,5 @@ waitForCompletion($id); } @@ -65,7 +67,7 @@ function getOrchestrationStatus(array $args, DurableClient $context): array function getEntitySnapshot(array $args, DurableClient $context): array { - $id = new EntityId($args['id']['name'], $args['id']['id']); + $id = EntityId($args['id']['name'], $args['id']['id']); return Serializer::serialize($context->getEntitySnapshot($id)); } @@ -90,7 +92,7 @@ function startOrchestration(array $args, DurableClient $context): array function raiseEvent(array $args, DurableClient $context): array { - $id = new OrchestrationInstance($args['id']['instance'], $args['id']['execution']); + $id = OrchestrationInstance($args['id']['instance'], $args['id']['execution']); $arguments = array_map( static fn($x, $i) => ['key' => $i, ...$x], $args['arguments'], @@ -104,7 +106,7 @@ function raiseEvent(array $args, DurableClient $context): array function signal(array $args, DurableClient $context): array { - $id = new EntityId($args['id']['name'], $args['id']['id']); + $id = EntityId($args['id']['name'], $args['id']['id']); $signal = $args['signal']; unset($args['id'], $args['signal']); diff --git a/src/Glue/glue.php b/src/Glue/glue.php index 9e91d418..d91b6daa 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -40,7 +40,6 @@ use Bottledcode\DurablePhp\State\EntityHistory; use Bottledcode\DurablePhp\State\Ids\StateId; use Bottledcode\DurablePhp\State\OrchestrationHistory; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\State\Serializer; use Bottledcode\DurablePhp\State\StateInterface; use Bottledcode\DurablePhp\Task; @@ -60,6 +59,8 @@ use ReflectionFunction; use Withinboredom\Time\Unit; +use function Bottledcode\DurablePhp\OrchestrationInstance; + require_once __DIR__ . '/autoload.php'; class Glue @@ -193,7 +194,7 @@ private function startOrchestration(): void { if (!$this->target->toOrchestrationInstance()->executionId) { $this->target = StateId::fromInstance( - new OrchestrationInstance( + OrchestrationInstance( $this->target->toOrchestrationInstance()->instanceId, Uuid::uuid7()->toString(), ), diff --git a/src/OrchestrationContext.php b/src/OrchestrationContext.php index c97b3fd6..01019b0e 100644 --- a/src/OrchestrationContext.php +++ b/src/OrchestrationContext.php @@ -76,12 +76,13 @@ public function callActivity(string $name, array $args = [], ?RetryOptions $retr $identity = $this->newGuid(); return $this->createFuture( - fn() => $this->taskController->fire( - AwaitResult::forEvent( - StateId::fromInstance($this->id), - WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)), + fn() + => $this->taskController->fire( + AwaitResult::forEvent( + StateId::fromInstance($this->id), + WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)), + ), ), - ), function (Event $event, string $eventIdentity) use ($identity): array { if (($event instanceof TaskCompleted || $event instanceof TaskFailed) && $eventIdentity === $identity->toString()) { @@ -114,7 +115,7 @@ private function createFuture( ?string $identity = null, ): DurableFuture { $identity ??= $this->history->historicalTaskResults->getIdentity(); - if (! $this->history->historicalTaskResults->hasSentIdentity($identity)) { + if (!$this->history->historicalTaskResults->hasSentIdentity($identity)) { $this->durableLogger->debug('Future requested for an unsent identity', [$identity]); [$eventId] = $onSent(); $deferred = new DeferredFuture(); @@ -141,7 +142,12 @@ public function callActivityInline(Closure $activity): DurableFuture return $this->createFuture(function () use ($activity, $identity) { try { $result = $activity(); - $this->taskController->fire(WithOrchestration::forInstance(StateId::fromInstance($this->id), TaskCompleted::forId($identity->toString(), $result))); + $this->taskController->fire( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + TaskCompleted::forId($identity->toString(), $result), + ), + ); return [$identity]; } catch (Throwable $exception) { @@ -160,7 +166,8 @@ public function callActivityInline(Closure $activity): DurableFuture return [$identity]; } }, function (Event $event, string $eventIdentity) use ($identity): array { - if (($event instanceof TaskCompleted || $event instanceof TaskFailed) && $eventIdentity === $identity->toString()) { + if (($event instanceof TaskCompleted || $event instanceof TaskFailed) && + $eventIdentity === $identity->toString()) { return [$event, true]; } @@ -189,7 +196,10 @@ public function continueAsNew(array $args = []): never $this->history->restartAsNew($args); $this->taskController->fire( - WithOrchestration::forInstance(StateId::fromInstance($this->id), StartOrchestration::forInstance($this->id)), + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + StartOrchestration::forInstance($this->id), + ), ); throw new Unwind(); } @@ -204,12 +214,13 @@ public function createTimer(DateTimeImmutable|DateInterval $fireAt): DurableFutu $identity = sha1($fireAt->format('c')); return $this->createFuture( - fn() => $this->taskController->fire( - WithOrchestration::forInstance( - StateId::fromInstance($this->id), - WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)), + fn() + => $this->taskController->fire( + WithOrchestration::forInstance( + StateId::fromInstance($this->id), + WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)), + ), ), - ), function (Event $event) use ($identity): array { if ($event instanceof RaiseEvent && $event->eventName === $identity) { return [$event, true]; @@ -300,13 +311,11 @@ public function createInterval( ?int $seconds = null, ?int $microseconds = null, ): DateInterval { - if ( - empty( - array_filter( - compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'), - ) + if (empty( + array_filter( + compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'), ) - ) { + )) { throw new LogicException('At least one interval part must be specified'); } @@ -361,7 +370,7 @@ public function isLockedOwned(EntityId $entityId): bool public function lockEntity(EntityId ...$entityId): EntityLock { $this->durableLogger->debug('Locking entities', ['entityId' => $entityId]); - if (! empty($this->history->locks ?? []) && ! $this->isReplaying()) { + if (!empty($this->history->locks ?? []) && !$this->isReplaying()) { throw new LogicException('Cannot lock an entity while holding locks'); } @@ -375,12 +384,11 @@ public function lockEntity(EntityId ...$entityId): EntityLock WithEntity::forInstance(current($entityId), RaiseEvent::forLockNotification($owner->id)), ); $identity = $this->newGuid()->toString(); - $future = - $this->createFuture( - fn() => $this->taskController->fire(WithLock::onEntity($owner, $event, ...$entityId)), - fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity], - $identity, - ); + $future = $this->createFuture( + fn() => $this->taskController->fire(WithLock::onEntity($owner, $event, ...$entityId)), + fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity], + $identity, + ); $this->waitOne($future); $this->history->locks = $entityId; @@ -439,17 +447,17 @@ public function waitAll(DurableFuture ...$tasks): array /** * @template T * - * @param class-string $className + * @param class-string $className * @return T */ public function createEntityProxy(string $className, ?EntityId $entityId = null): object { if ($entityId === null) { - $entityId = new EntityId($className, $this->newGuid()); + $entityId = EntityId($className, $this->newGuid()); } $class = new ReflectionClass($className); - if (! $class->isInterface()) { + if (!$class->isInterface()) { throw new LogicException('Only interfaces can be proxied'); } @@ -569,7 +577,7 @@ public function entityOp(string|EntityId $id, Closure $operation): mixed } $name = $type->getName(); - if (! interface_exists($name)) { + if (!interface_exists($name)) { throw new LogicException('Unable to load interface: ' . $name); } @@ -588,7 +596,7 @@ public function entityOp(string|EntityId $id, Closure $operation): mixed throw new LogicException('Did not call an operation'); } - $entityId = $id instanceof EntityId ? $id : new EntityId($name, $id); + $entityId = $id instanceof EntityId ? $id : EntityId($name, $id); if ($returns) { return $this->waitOne($this->callEntity($entityId, $operationName, $arguments)); diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 7a92953e..66e359f4 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -1,4 +1,5 @@ apiHost = rtrim($this->apiHost, '/'); + $this->apiHost = mb_rtrim($this->apiHost, '/'); } #[Override] @@ -56,7 +57,11 @@ public function cleanEntityStorage(): void {} #[Override] public function listEntities(EntityFilter $filter, int $page): Generator { - $req = new Request($this->apiHost . '/entities/filter/' . $page, 'POST', json_encode($filter, JSON_THROW_ON_ERROR)); + $req = new Request( + $this->apiHost . '/entities/filter/' . $page, + 'POST', + json_encode($filter, JSON_THROW_ON_ERROR), + ); if ($this->userToken) { $req->setHeader('Authorization', 'Bearer ' . $this->userToken); } @@ -82,7 +87,11 @@ public function signal(EntityId|string $entityId, Closure $signal): void } catch (Throwable) { // spies always throw } - $this->signalEntity(is_string($entityId) ? new EntityId($interfaceName, $entityId) : $entityId, $operationName, $arguments); + $this->signalEntity( + is_string($entityId) ? EntityId($interfaceName, $entityId) : $entityId, + $operationName, + $arguments, + ); } #[Override] diff --git a/src/State/EntityId.php b/src/State/EntityId.php index ad238392..7e7fe4f0 100644 --- a/src/State/EntityId.php +++ b/src/State/EntityId.php @@ -30,13 +30,28 @@ /** * @template T */ -class EntityId extends Record implements Stringable +readonly class EntityId extends Record implements Stringable { + public protected(set) string $name; + public protected(set) string $id; + /** * @param class-string $name * @param string $id + * @return static */ - public function __construct(protected(set) string $name, protected(set) string $id) {} + public static function from(string $name, string $id): static + { + return self::fromArgs(name: $name, id: $id); + } + + protected static function create(...$args): static + { + $obj = parent::create($args); + $obj->name = $args['name']; + $obj->id = $args['id']; + return $obj; + } public function __toString(): string { diff --git a/src/State/Ids/StateId.php b/src/State/Ids/StateId.php index cbfbc1ce..3f0a10cc 100644 --- a/src/State/Ids/StateId.php +++ b/src/State/Ids/StateId.php @@ -34,9 +34,14 @@ use Exception; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use RuntimeException; +use Stringable; + +use function Bottledcode\DurablePhp\EntityId; +use function Bottledcode\DurablePhp\OrchestrationInstance; #[ClassNameTypeMap('__type')] -readonly class StateId implements \Stringable +readonly class StateId implements Stringable { public function __construct(public string $id) {} @@ -84,7 +89,7 @@ public function toOrchestrationInstance(): OrchestrationInstance $parts = explode(':', $this->id, 3); return match ($parts) { ['activity', $parts[1]] => throw new Exception('Cannot convert activity state to orchestration instance'), - ['orchestration', $parts[1], $parts[2]] => new OrchestrationInstance($parts[1], $parts[2]), + ['orchestration', $parts[1], $parts[2]] => OrchestrationInstance($parts[1], $parts[2]), ['entity', $parts[1], $parts[2]] => throw new Exception( 'Cannot convert entity state to orchestration instance', ), @@ -99,7 +104,7 @@ public function toEntityId(): EntityId ['orchestration', $parts[1], $parts[2]] => throw new Exception( 'Cannot convert orchestration state to entity id', ), - ['entity', $parts[1], $parts[2]] => new EntityId($parts[1], $parts[2]), + ['entity', $parts[1], $parts[2]] => EntityId($parts[1], $parts[2]), }; } @@ -162,7 +167,7 @@ public function __invoke(string|StateId|OrchestrationInstance|EntityId|UuidInter return self::fromActivityId($id); } - throw new \RuntimeException("Cannot convert {$id} to StateId"); + throw new RuntimeException("Cannot convert {$id} to StateId"); } public function __toString(): string diff --git a/src/State/OrchestrationInstance.php b/src/State/OrchestrationInstance.php index 758534a6..a339dce6 100644 --- a/src/State/OrchestrationInstance.php +++ b/src/State/OrchestrationInstance.php @@ -27,9 +27,25 @@ use Stringable; use Withinboredom\Record; -class OrchestrationInstance extends Record implements Stringable +readonly class OrchestrationInstance extends Record implements Stringable { - public function __construct(protected(set) string $instanceId, protected(set) string $executionId) {} + public protected(set) string $instanceId; + + public protected(set) string $executionId; + + public static function from(string $instanceId, string $executionId): static + { + return static::fromArgs(instanceId: $instanceId, executionId: $executionId); + } + + protected static function create(...$args): static + { + $obj = parent::create($args); + $obj->instanceId = $args['instanceId']; + $obj->executionId = $args['executionId']; + + return $obj; + } public function __toString(): string { diff --git a/src/Testing/DummyOrchestrationContext.php b/src/Testing/DummyOrchestrationContext.php index 49831b98..9b4453e2 100644 --- a/src/Testing/DummyOrchestrationContext.php +++ b/src/Testing/DummyOrchestrationContext.php @@ -1,4 +1,5 @@ */ @@ -70,7 +73,15 @@ class DummyOrchestrationContext implements OrchestrationContextInterface public function __construct(public mixed $orchestration, private array $input) { - $this->status = new Status(new DateTimeImmutable(), '', SerializedArray::fromArray($input), StateId::fromInstance(new OrchestrationInstance('test', 'test')), new DateTimeImmutable(), null, RuntimeStatus::Running); + $this->status = new Status( + new DateTimeImmutable(), + '', + SerializedArray::fromArray($input), + StateId::fromInstance(OrchestrationInstance('test', 'test')), + new DateTimeImmutable(), + null, + RuntimeStatus::Running, + ); } public function handleActivities(ActivityMock ...$activities): void @@ -136,7 +147,7 @@ public function entityOp(EntityId|string $id, Closure $operation): mixed } $name = $type->getName(); - if (! interface_exists($name)) { + if (!interface_exists($name)) { throw new LogicException('Unable to load interface: ' . $name); } @@ -156,7 +167,7 @@ public function entityOp(EntityId|string $id, Closure $operation): mixed throw new LogicException('Did not call an operation'); } - $entityId = $id instanceof EntityId ? $id : new EntityId($name, $id); + $entityId = $id instanceof EntityId ? $id : EntityId($name, $id); if ($returns) { return $this->waitOne($this->callEntity($entityId, $operationName, $arguments)); @@ -177,8 +188,10 @@ public function callEntity( string $operation, array $args = [], ): DurableFuture { - return ($this->entities[$entityId->name] ?? throw new LogicException('Failed to find registered entity: ' . $entityId->name)) - ->mock->{$operation}(...$args); + return ($this->entities[$entityId->name] ?? + throw new LogicException('Failed to find registered entity: ' . $entityId->name))->mock->{$operation}( + ...$args, + ); } public function signalEntity( @@ -186,8 +199,10 @@ public function signalEntity( string $operation, array $args = [], ): void { - ($this->entities[$entityId->name] ?? throw new LogicException('Failed to find registered entity: ' . $entityId->name)) - ->mock->{$operation}(...$args); + ($this->entities[$entityId->name] ?? + throw new LogicException('Failed to find registered entity: ' . $entityId->name))->mock->{$operation}( + ...$args, + ); } public function isLockedOwned(EntityId $entityId): bool @@ -294,13 +309,11 @@ public function createInterval( ?int $seconds = null, ?int $microseconds = null, ): DateInterval { - if ( - empty( - array_filter( - compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'), - ) + if (empty( + array_filter( + compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'), ) - ) { + )) { throw new LogicException('At least one interval part must be specified'); } @@ -345,7 +358,7 @@ public function waitAll(DurableFuture ...$tasks): array { $results = []; foreach ($tasks as $task) { - if (! $task->future->isComplete()) { + if (!$task->future->isComplete()) { throw new LogicException('Not all futures are completed'); } $results[] = $task->getResult(); @@ -359,11 +372,11 @@ public function createEntityProxy( ?EntityId $entityId = null, ): object { if ($entityId === null) { - $entityId = new EntityId($className, $this->newGuid()); + $entityId = EntityId($className, $this->newGuid()); } $class = new ReflectionClass($className); - if (! $class->isInterface()) { + if (!$class->isInterface()) { throw new LogicException('Only interfaces can be proxied'); } diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 00000000..4789fcf7 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,25 @@ + $name + */ +function EntityId(string $name, string $id): EntityId +{ + return EntityId::from($name, $id); +} + +/** + * @template T + * @param class-string $instanceId + */ +function OrchestrationInstance(string $instanceId, string $executionId): OrchestrationInstance +{ + return OrchestrationInstance::from($instanceId, $executionId); +} diff --git a/tests/Feature/OrchestratorProxyTest.php b/tests/Feature/OrchestratorProxyTest.php index 346bc85e..fee55f0f 100644 --- a/tests/Feature/OrchestratorProxyTest.php +++ b/tests/Feature/OrchestratorProxyTest.php @@ -26,7 +26,8 @@ use Bottledcode\DurablePhp\DurableFuture; use Bottledcode\DurablePhp\Proxy\OrchestratorProxy; use Bottledcode\DurablePhp\Proxy\Pure; -use Bottledcode\DurablePhp\State\EntityId; + +use function Bottledcode\DurablePhp\EntityId; if (!interface_exists(orchProxy::class)) { interface orchProxy @@ -60,7 +61,7 @@ public function pureExample(int|float $number): string; new DurableFuture(new DeferredFuture()), ); $context->shouldReceive('signalEntity')->andReturn('signal'); - $proxy = new __OrchestratorProxy_orchProxy($context, new EntityId('test', 'test')); + $proxy = new __OrchestratorProxy_orchProxy($context, EntityId('test', 'test')); expect($proxy->callExample()) ->toBe('waited')->and($proxy->pureExample(1))->toBe('waited')->and($proxy->signalExample(1))->toBe(null); diff --git a/tests/PerformanceTests/Bank/BankTransaction.php b/tests/PerformanceTests/Bank/BankTransaction.php index a974a405..4ca61e50 100644 --- a/tests/PerformanceTests/Bank/BankTransaction.php +++ b/tests/PerformanceTests/Bank/BankTransaction.php @@ -25,7 +25,8 @@ namespace Bottledcode\DurablePhp\Tests\PerformanceTests\Bank; use Bottledcode\DurablePhp\OrchestrationContextInterface; -use Bottledcode\DurablePhp\State\EntityId; + +use function Bottledcode\DurablePhp\EntityId; class BankTransaction { @@ -36,13 +37,13 @@ public function __invoke(OrchestrationContextInterface $context) // generate the source account $sourceId = "src{$target}"; - $sourceEntity = new EntityId(Account::class, $sourceId); + $sourceEntity = EntityId(Account::class, $sourceId); // generate the destination account $destinationId = "dst{$target}"; - $destinationEntity = new EntityId(Account::class, $destinationId); + $destinationEntity = EntityId(Account::class, $destinationId); $feeId = "fee{$target}"; - $feeEntity = new EntityId(Account::class, $feeId); + $feeEntity = EntityId(Account::class, $feeId); // the amount to transfer $transferAmount = 1000; diff --git a/tests/PerformanceTests/PerformanceClient.php b/tests/PerformanceTests/PerformanceClient.php index 827c1967..fd693ecc 100644 --- a/tests/PerformanceTests/PerformanceClient.php +++ b/tests/PerformanceTests/PerformanceClient.php @@ -26,8 +26,6 @@ use Bottledcode\DurablePhp\DurableClient; use Bottledcode\DurablePhp\DurableLogger; -use Bottledcode\DurablePhp\State\EntityId; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\Tests\Common\LauncherEntity; use Bottledcode\DurablePhp\Tests\PerformanceTests\HelloCities\HelloSequence; use Bottledcode\DurablePhp\Tests\StopWatch; @@ -35,6 +33,8 @@ use function Amp\async; use function Amp\delay; use function Amp\Future\await; +use function Bottledcode\DurablePhp\EntityId; +use function Bottledcode\DurablePhp\OrchestrationInstance; require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/report.php'; @@ -50,11 +50,12 @@ $numberToLaunch = (getenv('ACTIVITY_COUNT') ?: 1000) / 200; $numberLaunchers = 200; for ($i = 0; $i < $numberLaunchers; $i++) { - async(fn() => $client->signalEntity( - new EntityId(LauncherEntity::class, $i), - 'launch', - ['orchestration' => HelloSequence::class, 'number' => $numberToLaunch, 'offset' => $i * $numberToLaunch], - )); + async(fn() + => $client->signalEntity( + EntityId(LauncherEntity::class, $i), + 'launch', + ['orchestration' => HelloSequence::class, 'number' => $numberToLaunch, 'offset' => $i * $numberToLaunch], + )); } delay(1); @@ -63,7 +64,11 @@ $ids = array_chunk($ids, 50); foreach ($ids as $num => $chunk) { - $getters = array_map(static fn($id) => async(fn() => $client->waitForCompletion(new OrchestrationInstance(HelloSequence::class, $id))), $chunk); + $getters = array_map( + static fn($id) + => async(fn() => $client->waitForCompletion(OrchestrationInstance(HelloSequence::class, $id))), + $chunk, + ); $logger->alert(sprintf('Waiting for chunk %d of %d', $num, count($ids))); await($getters); } diff --git a/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php b/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php index b9bbf82c..5e705aa6 100644 --- a/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php +++ b/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php @@ -3,7 +3,8 @@ namespace Bottledcode\DurablePhp\Tests\PerformanceTests\src\Benchmarks\Bank; use Bottledcode\DurablePhp\Attributes\Orchestration; -use Bottledcode\DurablePhp\EntityId; + +use function Bottledcode\DurablePhp\EntityId; #[Orchestration] function BankTransaction($context): bool @@ -11,10 +12,10 @@ function BankTransaction($context): bool $pair = $context->getInput(); $sourceId = sprintf('src%d-!-%d', $pair, ($pair + 1) % 32); - $sourceEntity = new EntityId(AccountInterface::class, $sourceId); + $sourceEntity = EntityId(AccountInterface::class, $sourceId); $destinationId = sprintf('dst%d-!%d', $pair, ($pair + 2) % 32); - $destinationEntity = new EntityId(AccountInterface::class, $destinationId); + $destinationEntity = EntityId(AccountInterface::class, $destinationId); $transferAmount = 1000; $sourceProxy = $context->createProxy(AccountInterface::class, $sourceEntity); diff --git a/tests/Pest.php b/tests/Pest.php index dd073d77..545a951f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -59,16 +59,17 @@ use Bottledcode\DurablePhp\Proxy\SpyProxy; use Bottledcode\DurablePhp\State\AbstractHistory; use Bottledcode\DurablePhp\State\EntityHistory; -use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; use Bottledcode\DurablePhp\State\Ids\StateId; use Bottledcode\DurablePhp\State\OrchestrationHistory; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\State\RuntimeStatus; use Bottledcode\DurablePhp\State\Status; use Bottledcode\DurablePhp\Task; use DI\Container; +use function Bottledcode\DurablePhp\EntityId; +use function Bottledcode\DurablePhp\OrchestrationInstance; + $_SERVER['SERVER_PROTOCOL'] = 'DPHP/1.0'; expect()->extend('toBeOne', fn() => $this->toBe(1)); @@ -77,7 +78,10 @@ /** @var Status $otherStatus */ $otherStatus = $this->value->getStatus(); - return expect($otherStatus->runtimeStatus)->toBe($status, "Expected status {$status->name} but got {$otherStatus->runtimeStatus->name}"); + return expect($otherStatus->runtimeStatus)->toBe( + $status, + "Expected status {$status->name} but got {$otherStatus->runtimeStatus->name}", + ); }); expect()->extend('toHaveOutput', fn(mixed $output) => expect(getStatusOutput($this->value))->toBe($output)); @@ -166,8 +170,8 @@ public function fire(Event ...$events): array function getEntityHistory(?EntityState $withState = null): EntityHistory { static $id = 0; - $withState ??= new class () extends EntityState {}; - $entityId = new EntityId('test', $id++); + $withState ??= new class extends EntityState {}; + $entityId = EntityId('test', $id++); $history = new EntityHistory(StateId::fromEntityId($entityId), new DurableLogger(), new Provenance('', [])); $reflector = new ReflectionClass($history); $reflector->getProperty('state')->setValue($history, $withState); @@ -196,7 +200,11 @@ function getOrchestration( $instance => $orchestration, ], ); - $history = new OrchestrationHistory(StateId::fromInstance(new OrchestrationInstance($instance, $id)), new DurableLogger(), new Provenance('', [])); + $history = new OrchestrationHistory( + StateId::fromInstance(OrchestrationInstance($instance, $id)), + new DurableLogger(), + new Provenance('', []), + ); $history->setContainer($container); $startupEvent ??= StartExecution::asParent($input, []); $startupEvent = WithOrchestration::forInstance($history->id, $startupEvent); diff --git a/tests/Unit/ActivityHistoryTest.php b/tests/Unit/ActivityHistoryTest.php index c14740b7..29dc0df8 100644 --- a/tests/Unit/ActivityHistoryTest.php +++ b/tests/Unit/ActivityHistoryTest.php @@ -31,11 +31,12 @@ use Bottledcode\DurablePhp\Events\WithActivity; use Bottledcode\DurablePhp\Glue\Provenance; use Bottledcode\DurablePhp\State\ActivityHistory; -use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\Ids\StateId; use DI\Container; use Ramsey\Uuid\Uuid; +use function Bottledcode\DurablePhp\EntityId; + function activity(bool $fail): void { if ($fail) { @@ -50,16 +51,16 @@ function activity(bool $fail): void it('real: fails on an exception', function (): void { $history = new ActivityHistory(StateId::fromActivityId(Uuid::uuid7()), null, new Provenance('', [])); $event = AwaitResult::forEvent( - StateId::fromEntityId(new EntityId('test', 'test')), + StateId::fromEntityId(EntityId('test', 'test')), WithActivity::forEvent(Uuid::uuid7(), ScheduleTask::forName(__NAMESPACE__ . '\activity', [true])), ); $result1 = processEvent($event, $history->applyScheduleTask(...)); expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class); $result2 = processEvent($event, $history->applyScheduleTask(...)); - expect($result2)->toHaveCount(1) - ->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class) - ->and(current($result1))->toEqual(current($result2)); + expect($result2) + ->toHaveCount(1)->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class)->and(current($result1)) + ->toEqual(current($result2)); }); it('succeeds on no exception', function (): void { @@ -67,14 +68,17 @@ function activity(bool $fail): void $container = new Container([__NAMESPACE__ . '\activity' => activity(...)]); $history->setContainer($container); $event = AwaitResult::forEvent( - StateId::fromEntityId(new EntityId('test', 'test')), + StateId::fromEntityId(EntityId('test', 'test')), WithActivity::forEvent(Uuid::uuid7(), ScheduleTask::forName(__NAMESPACE__ . '\activity', [false])), ); $result1 = processEvent($event, $history->applyScheduleTask(...)); - expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent()->getInnerEvent())->toBeInstanceOf(TaskCompleted::class); + expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent()->getInnerEvent())->toBeInstanceOf( + TaskCompleted::class, + ); $result2 = processEvent($event, $history->applyScheduleTask(...)); - expect($result2)->toHaveCount(1) - ->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskCompleted::class) - ->and(current($result1))->toEqual(current($result2)); + expect($result2) + ->toHaveCount(1)->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskCompleted::class)->and( + current($result1), + )->toEqual(current($result2)); }); diff --git a/tests/Unit/EntityHistoryTest.php b/tests/Unit/EntityHistoryTest.php index 11587f58..c27be01e 100644 --- a/tests/Unit/EntityHistoryTest.php +++ b/tests/Unit/EntityHistoryTest.php @@ -1,4 +1,5 @@ applyRaiseEvent(...), ); - expect($unlockResult)->toContain($waiting) - ->and($called)->toBe(1); + expect($unlockResult) + ->toContain($waiting)->and($called)->toBe(1); }); it('properly locks in a chain', function (): void { @@ -135,8 +137,8 @@ public function signal(): void }, ); - $owner = StateId::fromInstance(new OrchestrationInstance('owner', 'owner')); - $other = StateId::fromInstance(new OrchestrationInstance('other', 'other')); + $owner = StateId::fromInstance(OrchestrationInstance('owner', 'owner')); + $other = StateId::fromInstance(OrchestrationInstance('other', 'other')); $otherEntity = getEntityHistory(); @@ -168,8 +170,8 @@ public function signal(): void // send the first lock notification in the chain $firstResult = processEvent($firstLock, $otherEntity->applyRaiseEvent(...)); - expect($firstResult)->toHaveCount(3) - ->and($firstResult[0]->innerEvent->target->id)->toBe($history->id->id); + expect($firstResult) + ->toHaveCount(3)->and($firstResult[0]->innerEvent->target->id)->toBe($history->id->id); // send a signal to be run once the lock is complete $locked = processEvent($actualEvent, $history->applyRaiseEvent(...)); @@ -177,8 +179,8 @@ public function signal(): void // complete the lock sequence $secondResult = processEvent($firstResult[0], $history->applyRaiseEvent(...)); - expect($secondResult)->toHaveCount(3) - ->and($secondResult[0])->toBeInstanceOf(WithEntity::class); + expect($secondResult) + ->toHaveCount(3)->and($secondResult[0])->toBeInstanceOf(WithEntity::class); // process the actual event earlier $finalResult = processEvent($secondResult[0], $history->applyRaiseEvent(...)); diff --git a/tests/Unit/LockIntegrationTest.php b/tests/Unit/LockIntegrationTest.php index 37c98e91..47b43ac8 100644 --- a/tests/Unit/LockIntegrationTest.php +++ b/tests/Unit/LockIntegrationTest.php @@ -1,4 +1,5 @@ lockEntity(new EntityId('test', 'test')); + $lock = $context->lockEntity(EntityId('test', 'test')); expect($lock->isLocked())->toBeTrue(); - $result = $context->callEntity(new EntityId('test', 'test'), 'test'); + $result = $context->callEntity(EntityId('test', 'test'), 'test'); $result = $context->waitOne($result); expect($result)->toBe('hello world'); $lock->unlock(); @@ -39,12 +41,14 @@ return $result; }, [], $nextEvent); - $entity = getEntityHistory(new class () extends EntityState { - public function test() - { - return 'hello world'; - } - }); + $entity = getEntityHistory( + new class extends EntityState { + public function test() + { + return 'hello world'; + } + }, + ); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); $instance->resetState(); diff --git a/tests/Unit/OrchestrationHistoryTest.php b/tests/Unit/OrchestrationHistoryTest.php index 4ef7e8a3..93e9e1f9 100644 --- a/tests/Unit/OrchestrationHistoryTest.php +++ b/tests/Unit/OrchestrationHistoryTest.php @@ -1,4 +1,5 @@ true, [], $nextEvent); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Completed); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed); }); class SerializedType @@ -62,10 +64,13 @@ public function entry(string $test, SerializedType $type): string } }; - $instance = getOrchestration(id: 'test', orchestration: $orchestration, input: ['test' => 'hello world', 'type' => Serializer::serialize(new SerializedType('test'))], nextEvent: $nextEvent); + $instance = getOrchestration(id: 'test', orchestration: $orchestration, input: [ + 'test' => 'hello world', + 'type' => Serializer::serialize(new SerializedType('test')), + ], nextEvent: $nextEvent); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Completed); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed); }); it('example: can handle oop orchestration', function (): void { @@ -90,11 +95,11 @@ public function entry(string $test, SerializedType $type): string fn() => true, [], $nextEvent, - StartExecution::asChild(new OrchestrationInstance('parent', 'parent'), [], []), + StartExecution::asChild(OrchestrationInstance('parent', 'parent'), [], []), ); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); - expect($result)->toHaveCount(1) - ->and($instance)->toHaveStatus(RuntimeStatus::Completed); + expect($result) + ->toHaveCount(1)->and($instance)->toHaveStatus(RuntimeStatus::Completed); }); it('properly delays when using timers', function (): void { @@ -110,9 +115,9 @@ public function entry(string $test, SerializedType $type): string expect($timer)->toHaveCount(1); $instance->resetState(); $result = processEvent($timer[0], $instance->applyRaiseEvent(...)); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Completed) - ->and(getStatusOutput($instance))->toBeTrue(); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed)->and(getStatusOutput($instance)) + ->toBeTrue(); }); it('properly delays when using timers (example)', function (): void { @@ -142,30 +147,33 @@ public function entry(string $test, SerializedType $type): string }, [], $nextEvent); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); $instance->resetState(); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Running); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running); $result = processEvent( - WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', SerializedArray::fromArray([])->toArray())), + WithOrchestration::forInstance( + $instance->id, + new RaiseEvent('', 'test', SerializedArray::fromArray([])->toArray()), + ), $instance->applyRaiseEvent(...), ); $instance->resetState(); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Running); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running); $result = processEvent( WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', [])), $instance->applyRaiseEvent(...), ); $instance->resetState(); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Running); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running); $result = processEvent( WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', [])), $instance->applyRaiseEvent(...), ); $instance->resetState(); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Completed) - ->and(getStatusOutput($instance))->toBeTrue(); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed)->and(getStatusOutput($instance)) + ->toBeTrue(); }); it('can wait for a signal after starting (example)', function (): void { @@ -186,7 +194,12 @@ public function entry(string $test, SerializedType $type): string }); it('can call an activity with a successful result', function (): void { - $instance = getOrchestration('test', fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), [], $nextEvent); + $instance = getOrchestration( + 'test', + fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), + [], + $nextEvent, + ); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); $instance->resetState(); @@ -195,27 +208,35 @@ public function entry(string $test, SerializedType $type): string WithOrchestration::forInstance($instance->id, TaskCompleted::forId($result[0]->eventId, 'pretty colors')), $instance->applyTaskCompleted(...), ); - expect($result)->toBeEmpty() - ->and($instance)->toHaveOutput('pretty colors') - ->and($instance)->toHaveStatus(RuntimeStatus::Completed); + expect($result) + ->toBeEmpty()->and($instance)->toHaveOutput('pretty colors')->and($instance)->toHaveStatus( + RuntimeStatus::Completed, + ); }); it('can call an activity with a successful result (example)', function (): void { - $instance = fn(OrchestrationContextInterface $context) => $context->waitOne($context->callActivity('test', ['hello world'])); + $instance = fn(OrchestrationContextInterface $context) + => $context->waitOne($context->callActivity('test', ['hello world'])); $context = new DummyOrchestrationContext($instance, []); $context->handleActivities(new ActivityMock('test', 'pretty colors')); expect($instance($context))->toBe(['pretty colors']); }); it('can call an activity with a failed result (example)', function (): void { - $instance = fn(OrchestrationContextInterface $context) => $context->waitOne($context->callActivity('test', ['hello world'])); + $instance = fn(OrchestrationContextInterface $context) + => $context->waitOne($context->callActivity('test', ['hello world'])); $context = new DummyOrchestrationContext($instance, []); $context->handleActivities(new ActivityMock('test', new Exception('hello world'))); expect(fn() => $instance($context))->toThrow(Exception::class, 'hello world'); }); it('can call an activity with a failed result', function (): void { - $instance = getOrchestration('test', fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), [], $nextEvent); + $instance = getOrchestration( + 'test', + fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), + [], + $nextEvent, + ); $result = processEvent($nextEvent, $instance->applyStartOrchestration(...)); $instance->resetState(); @@ -224,6 +245,6 @@ public function entry(string $test, SerializedType $type): string WithOrchestration::forInstance($instance->id, TaskFailed::forTask($result[0]->eventId, 'pretty colors')), $instance->applyTaskFailed(...), ); - expect($result)->toBeEmpty() - ->and($instance)->toHaveStatus(RuntimeStatus::Failed); + expect($result) + ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Failed); }); From e0f51519c78ef83dc358613412ce150fced44430 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 10:53:02 +0200 Subject: [PATCH 18/51] convert state ids to records Signed-off-by: Robert Landers --- src/State/Ids/StateId.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/State/Ids/StateId.php b/src/State/Ids/StateId.php index 3f0a10cc..d7c1f8e5 100644 --- a/src/State/Ids/StateId.php +++ b/src/State/Ids/StateId.php @@ -36,14 +36,15 @@ use Ramsey\Uuid\UuidInterface; use RuntimeException; use Stringable; +use Withinboredom\Record; use function Bottledcode\DurablePhp\EntityId; use function Bottledcode\DurablePhp\OrchestrationInstance; #[ClassNameTypeMap('__type')] -readonly class StateId implements Stringable +readonly class StateId extends Record implements Stringable { - public function __construct(public string $id) {} + public protected(set) string $id; public static function fromState(StateInterface $state): self { @@ -56,17 +57,24 @@ public static function fromState(StateInterface $state): self public static function fromInstance(OrchestrationInstance $instance): self { - return new self("orchestration:{$instance}"); + return self::fromArgs(id: "orchestration:{$instance}"); } public static function fromActivityId(UuidInterface|string $activityId): self { - return new self("activity:{$activityId}"); + return self::fromArgs(id: "activity:{$activityId}"); } public static function fromEntityId(EntityId $entityId): self { - return new self("entity:{$entityId}"); + return self::fromArgs(id: "entity:{$entityId}"); + } + + protected static function create(...$args): static + { + $obj = parent::create($args); + $obj->id = $args['id']; + return $obj; } public function toActivityId(): string @@ -81,7 +89,7 @@ public function toActivityId(): string public static function fromString(string $id): self { - return new self($id); + return self::fromArgs(id: $id); } public function toOrchestrationInstance(): OrchestrationInstance From 0ce9fa0176a26096f6ea3d975d11a7f32a311ea6 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 11:02:56 +0200 Subject: [PATCH 19/51] handle nullables Signed-off-by: Robert Landers --- src/Proxy/Generator.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Proxy/Generator.php b/src/Proxy/Generator.php index 80fe09a6..b6436b10 100644 --- a/src/Proxy/Generator.php +++ b/src/Proxy/Generator.php @@ -135,10 +135,11 @@ abstract protected function impureCall(ReflectionMethod $method, bool $isHook = protected function getTypes(ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type): string { if ($type instanceof ReflectionNamedType) { + $nullable = $type->allowsNull() ? '?' : ''; if ($type->isBuiltin()) { - return $type->getName(); + return $nullable . $type->getName(); } - return '\\' . $type->getName(); + return '\\' . $nullable . $type->getName(); } if ($type instanceof ReflectionUnionType) { From b2d8b1c8a56c0743bb4fbb83f1ecf15d53fd2a4e Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 11:04:34 +0200 Subject: [PATCH 20/51] fix order of ? Signed-off-by: Robert Landers --- src/Proxy/Generator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Proxy/Generator.php b/src/Proxy/Generator.php index b6436b10..0e19b812 100644 --- a/src/Proxy/Generator.php +++ b/src/Proxy/Generator.php @@ -139,7 +139,7 @@ protected function getTypes(ReflectionNamedType|ReflectionUnionType|ReflectionIn if ($type->isBuiltin()) { return $nullable . $type->getName(); } - return '\\' . $nullable . $type->getName(); + return $nullable . '\\' . $type->getName(); } if ($type instanceof ReflectionUnionType) { From 0bcf7a4c7bdb7ac008d3191d07a623d86bfb84a9 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 13:52:55 +0200 Subject: [PATCH 21/51] fix event descriptions Signed-off-by: Robert Landers --- .idea/php.xml | 3 + src/Events/EventDescription.php | 47 ++-- src/Events/WithLock.php | 7 +- tests/Unit/EventDescriptionTest.php | 339 ++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/EventDescriptionTest.php diff --git a/.idea/php.xml b/.idea/php.xml index 3afa7145..6301b492 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -32,6 +32,9 @@ + + diff --git a/src/Events/EventDescription.php b/src/Events/EventDescription.php index 37e3630d..f9c1529c 100644 --- a/src/Events/EventDescription.php +++ b/src/Events/EventDescription.php @@ -1,4 +1,5 @@ replyTo = $event->getReplyTo(); } @@ -93,14 +96,14 @@ private function describe(Event $event): void $this->meta = Serializer::serialize($event); } - $reflection = new \ReflectionClass($event); - foreach($reflection->getAttributes(NeedsTarget::class) as $target) { + $reflection = new ReflectionClass($event); + foreach ($reflection->getAttributes(NeedsTarget::class) as $target) { /** @var NeedsTarget $attr */ $attr = $target->newInstance(); $targetOps[] = $attr->operation; } - foreach($reflection->getAttributes(NeedsSource::class) as $target) { + foreach ($reflection->getAttributes(NeedsSource::class) as $target) { /** @var NeedsTarget $attr */ $attr = $target->newInstance(); $sourceOps[] = $attr->operation; @@ -109,14 +112,24 @@ private function describe(Event $event): void $event = $event->getInnerEvent(); } - $reflection = new \ReflectionClass($event); - foreach($reflection->getAttributes(NeedsTarget::class) as $target) { + if ($event instanceof PoisonPill) { + $this->isPoisoned = true; + } + if ($event instanceof External) { + $this->meta = Serializer::serialize($event); + } + if ($event instanceof ReplyToInterface) { + $this->replyTo = $event->getReplyTo(); + } + + $reflection = new ReflectionClass($event); + foreach ($reflection->getAttributes(NeedsTarget::class) as $target) { /** @var NeedsTarget $attr */ $attr = $target->newInstance(); $targetOps[] = $attr->operation; } - foreach($reflection->getAttributes(NeedsSource::class) as $target) { + foreach ($reflection->getAttributes(NeedsSource::class) as $target) { /** @var NeedsTarget $attr */ $attr = $target->newInstance(); $sourceOps[] = $attr->operation; @@ -131,11 +144,12 @@ private function describe(Event $event): void $this->replyTo ??= null; $this->scheduledAt ??= null; $this->destination ??= null; + $this->meta ??= []; $this->targetType = match (true) { - $this->destination->isActivityId() => TargetType::Activity, - $this->destination->isOrchestrationId() => TargetType::Orchestration, - $this->destination->isEntityId() => TargetType::Entity, + $this->destination?->isActivityId() => TargetType::Activity, + $this->destination?->isOrchestrationId() => TargetType::Orchestration, + $this->destination?->isEntityId() => TargetType::Entity, default => TargetType::None, }; } @@ -150,22 +164,25 @@ public static function fromStream(string $data): self } /** - * @throws \JsonException + * @throws JsonException */ public static function fromJson(string $json): EventDescription { - return new EventDescription(Serializer::deserialize(json_decode($json, true, 512, JSON_THROW_ON_ERROR), Event::class)); + return new EventDescription( + Serializer::deserialize(json_decode($json, true, 512, JSON_THROW_ON_ERROR), Event::class), + ); } public function toStream(): string { - $serialized = function_exists('igbinary_serialize') ? igbinary_serialize($this->event) : serialize($this->event); + $serialized = + function_exists('igbinary_serialize') ? igbinary_serialize($this->event) : serialize($this->event); $serialized = function_exists('gzencode') ? gzencode($serialized) : $serialized; $event = base64_encode($serialized); return json_encode([ - 'destination' => $this->destination->id, + 'destination' => $this->destination?->id ?? null, 'replyTo' => $this->replyTo?->id ?? '', 'scheduleAt' => $this->scheduledAt?->format(DATE_ATOM) ?? gmdate(DATE_ATOM, time() - 30), 'eventId' => $this->eventId, @@ -179,7 +196,7 @@ public function toStream(): string } /** - * @throws \JsonException + * @throws JsonException */ public function toJson(): string { diff --git a/src/Events/WithLock.php b/src/Events/WithLock.php index 9fe2e142..b387b1ad 100644 --- a/src/Events/WithLock.php +++ b/src/Events/WithLock.php @@ -34,10 +34,7 @@ class WithLock extends Event implements HasInnerEventInterface { /** - * @param string $eventId - * @param StateId $owner - * @param array $participants - * @param Event $innerEvent + * @param array $participants */ public function __construct( string $eventId, @@ -46,7 +43,7 @@ public function __construct( public array $participants, public Event $innerEvent, ) { - parent::__construct($this->innerEvent ?: Uuid::uuid7()); + parent::__construct($this->innerEvent->eventId ?: Uuid::uuid7()); } public static function onEntity(StateId $owner, Event $innerEvent, StateId ...$targets): self diff --git a/tests/Unit/EventDescriptionTest.php b/tests/Unit/EventDescriptionTest.php new file mode 100644 index 00000000..0d04cd5c --- /dev/null +++ b/tests/Unit/EventDescriptionTest.php @@ -0,0 +1,339 @@ +toString()); + } + + public function __toString(): string + { + return 'SimpleEvent()'; + } +} + +#[NeedsTarget(Operation::Signal)] +class EventWithTargetAttribute extends Event +{ + public function __construct(string $eventId = '') + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function __toString(): string + { + return 'EventWithTargetAttribute()'; + } +} + +#[NeedsSource(Operation::Call)] +class EventWithSourceAttribute extends Event +{ + public function __construct(string $eventId = '') + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function __toString(): string + { + return 'EventWithSourceAttribute()'; + } +} + +class EventWithReplyTo extends Event implements ReplyToInterface +{ + public function __construct(string $eventId, private string $replyToId) + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function getReplyTo(): StateId + { + // In a real test, we'd need to return an actual StateId + // For our test purposes, we'll mock this behavior + return StateId::fromString($this->replyToId); + } + + public function __toString(): string + { + return 'EventWithReplyTo()'; + } +} + +class EventWithTarget extends Event implements StateTargetInterface +{ + public function __construct( + string $eventId, + private string $targetId, + ) { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function getTarget(): StateId + { + // In a real test, we'd need to return an actual StateId + // For our test purposes, we'll mock this behavior + return StateId::fromString($this->targetId); + } + + public function __toString(): string + { + return 'EventWithTarget()'; + } +} + +class MockExternalEvent extends Event implements External +{ + public string $externalData = 'external data'; + + public function __construct(string $eventId = '') + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function __toString(): string + { + return 'MockExternalEvent()'; + } +} + +class MockWrapperEvent extends Event implements HasInnerEventInterface +{ + public function __construct(string $eventId = '', private Event $inner = new SimpleEvent()) + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function getInnerEvent(): Event + { + return $this->inner; + } + + public function __toString(): string + { + return 'MockWrapperEvent(' . $this->inner . ')'; + } +} + +// Tests for EventDescription constructor and describe method +test('EventDescription constructor with simple event', function (): void { + $event = new SimpleEvent('test-id'); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id') + ->and($description->innerEvent)->toBe($event) + ->and($description->locks)->toBeFalse() + ->and($description->isPoisoned)->toBeFalse() + ->and($description->replyTo)->toBeNull() + ->and($description->scheduledAt)->toBeNull() + ->and($description->destination)->toBeNull() + ->and($description->targetType)->toBe(TargetType::None) + ->and($description->sourceOperations)->toBeEmpty() + ->and($description->targetOperations)->toBeEmpty(); +}); + +test('EventDescription constructor with event that has target attribute', function (): void { + $event = new EventWithTargetAttribute('test-id'); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id') + ->and($description->innerEvent)->toBe($event) + ->and($description->targetOperations)->toHaveCount(1) + ->and($description->targetOperations[0])->toBe(Operation::Signal); +}); + +test('EventDescription constructor with event that has source attribute', function (): void { + $event = new EventWithSourceAttribute('test-id'); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id'); + expect($description->innerEvent)->toBe($event); + expect($description->sourceOperations)->toHaveCount(1); + expect($description->sourceOperations[0])->toBe(Operation::Call); +}); + +test('EventDescription constructor with event that implements ReplyToInterface', function (): void { + $event = AwaitResult::forEvent(StateId::fromString('orchestration:instance:reply-to-id'), new SimpleEvent('test-id')); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id'); + expect($description->replyTo)->not()->toBeNull(); + expect((string) $description->replyTo)->toBe('orchestration:instance:reply-to-id'); +}); + +test('EventDescription constructor with event that implements StateTargetInterface', function (): void { + $event = WithOrchestration::forInstance(StateId::fromString('activity:target-id'), new SimpleEvent('test-id')); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id'); + expect($description->destination)->not()->toBeNull(); + expect((string) $description->destination)->toBe('activity:target-id'); + expect($description->targetType)->toBe(TargetType::Activity); +}); + +test('EventDescription constructor with WithDelay event', function (): void { + $innerEvent = StartOrchestration::forInstance(OrchestrationInstance('instance', 'inner-id')); + $fireAt = new DateTimeImmutable('2023-01-01 12:00:00'); + $event = WithDelay::forEvent($fireAt, $innerEvent); + $description = new EventDescription($event); + + expect($description->destination)->toBe(StateId::fromString('orchestration:instance:inner-id')); + expect($description->scheduledAt)->toBe($fireAt); + expect($description->innerEvent)->toBe($innerEvent->getInnerEvent()); +}); + +test('EventDescription constructor with WithLock event', function (): void { + $innerEvent = new SimpleEvent('inner-id'); + $owner = StateId::fromString('orchestration:instance:owner-id'); + $target = StateId::fromString('entity:target-id'); + $event = WithLock::onEntity($owner, $innerEvent, $target); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('inner-id'); + expect($description->locks)->toBeTrue(); + expect($description->innerEvent)->toBe($innerEvent); + expect($description->targetOperations)->toHaveCount(1); + expect($description->targetOperations[0])->toBe(Operation::Lock); +}); + +test('EventDescription constructor with PoisonPill event', function (): void { + $event = PoisonPill::digest(); + $description = new EventDescription($event); + + expect($description->isPoisoned)->toBeTrue(); +}); + +test('EventDescription constructor with External event', function (): void { + $event = new MockExternalEvent('test-id'); + $description = new EventDescription($event); + + expect($description->eventId)->toBe('test-id'); + expect($description->meta)->toBeArray(); + expect($description->meta)->toHaveKey('externalData'); + expect($description->meta['externalData'])->toBe('external data'); +}); + +test('EventDescription constructor with nested events', function (): void { + $innerEvent = new EventWithTargetAttribute('inner-id'); + $wrapperEvent = new MockWrapperEvent('wrapper-id', $innerEvent); + $description = new EventDescription($wrapperEvent); + + expect($description->eventId)->toBe('wrapper-id'); + expect($description->innerEvent)->toBe($innerEvent); + expect($description->targetOperations)->toHaveCount(1); + expect($description->targetOperations[0])->toBe(Operation::Signal); +}); + +// Tests for serialization/deserialization methods +test('toStream method', function (): void { + $event = new SimpleEvent('test-id'); + $description = new EventDescription($event); + + $stream = $description->toStream(); + $stream = json_decode($stream, true); + $result = EventDescription::fromStream($stream['event']); + + expect($result)->toEqual($description); +}); + +test('toJson method', function (): void { + $event = new SimpleEvent('test-id'); + $description = new EventDescription($event); + + $json = $description->toJson(); + $result = EventDescription::fromJson($json); + // hack around serialization of timestamps + $description = new EventDescription($event->with(timestamp: $result->event->timestamp)); + expect($result)->toEqual($description); +}); + +// Edge cases +test('EventDescription handles multiple attributes of the same type', function (): void { + #[NeedsTarget(Operation::Signal)] + #[NeedsTarget(Operation::Call)] + class EventWithMultipleAttributes extends Event + { + public function __construct(string $eventId = '') + { + parent::__construct($eventId ?: Uuid::uuid7()->toString()); + } + + public function __toString(): string + { + return 'EventWithMultipleAttributes()'; + } + } + + $event = new EventWithMultipleAttributes('test-id'); + $description = new EventDescription($event); + + expect($description->targetOperations)->toHaveCount(2); + expect($description->targetOperations)->toContain(Operation::Signal); + expect($description->targetOperations)->toContain(Operation::Call); +}); + +test('EventDescription handles different target types', function (): void { + $testCases = [ + ['isActivity' => true, 'expected' => TargetType::Activity], + ['isOrchestration' => true, 'expected' => TargetType::Orchestration], + ['isEntity' => true, 'expected' => TargetType::Entity], + ]; + + foreach ($testCases as $case) { + $targetId = StateId::fromString(match ($case) { + [...$case, 'isActivity' => true] => 'activity:id', + [...$case, 'isOrchestration' => true] => 'orchestration:instance:id', + [...$case, 'isEntity' => true] => 'entity:id', + }); + $event = new WithOrchestration('test-id', $targetId, TaskFailed::forTask('123', 'test')); + $description = new EventDescription($event); + + expect($description->targetType)->toBe($case['expected']); + } +}); From b2acc56d6c6a1170f98777942a7e3bf5d47b97a8 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 23:18:17 +0200 Subject: [PATCH 22/51] handle serialization Signed-off-by: Robert Landers --- composer.json | 24 ++++++++-------- src/Events/EventDescription.php | 5 +++- src/Glue/glue.php | 9 +++--- src/State/EntityId.php | 13 ++------- src/State/Exporter.php | 43 +++++++++++++++++++++++++++++ src/State/OrchestrationInstance.php | 9 ------ src/State/Serializer.php | 12 +++----- tests/Unit/EventDescriptionTest.php | 3 ++ tests/Unit/RecordTest.php | 22 +++++++++++++++ 9 files changed, 94 insertions(+), 46 deletions(-) create mode 100644 src/State/Exporter.php create mode 100644 tests/Unit/RecordTest.php diff --git a/composer.json b/composer.json index 2d1c66cb..754f88a0 100644 --- a/composer.json +++ b/composer.json @@ -29,25 +29,25 @@ "license": "MIT", "name": "bottledcode/durable-php", "require": { - "adhocore/cli": "^1.7.1", - "amphp/file": "^3.1.1", - "amphp/http-client": "^5.1.0", + "adhocore/cli": "^1.9.4", + "amphp/file": "^3.2.0", + "amphp/http-client": "^5.3.3", "amphp/log": "^v2.0.0", - "amphp/parallel": "^2.2.9", - "crell/serde": "^1.2.0", + "amphp/parallel": "^2.3.1", + "crell/serde": "^1.5.0", "nesbot/carbon": ">2.0", - "nikic/php-parser": "^5.1", + "nikic/php-parser": "^5.6", "php": ">=8.4", - "php-di/php-di": "^7.0.7", - "ramsey/uuid": "^4.7.6", - "webonyx/graphql-php": "^15.12.5", - "withinboredom/records": "v0.1.2", + "php-di/php-di": "^7.0.11", + "ramsey/uuid": "^4.9.0", + "webonyx/graphql-php": "^15.22.0", + "withinboredom/records": "^0.1.3", "withinboredom/time": "^6.0.0" }, "require-dev": { - "laravel/pint": "^1.17.2", + "laravel/pint": "^1.24.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.35.1 || ^3.0.0" + "pestphp/pest": "^2.35.1 || ^3.8.2" }, "scripts": { "test": "pest" diff --git a/src/Events/EventDescription.php b/src/Events/EventDescription.php index f9c1529c..44ad60f0 100644 --- a/src/Events/EventDescription.php +++ b/src/Events/EventDescription.php @@ -159,6 +159,7 @@ public static function fromStream(string $data): self $data = base64_decode($data, true); $data = function_exists('gzdecode') ? gzdecode($data) : $data; $data = function_exists('igbinary_unserialize') ? igbinary_unserialize($data) : unserialize($data); + $data = Serializer::deserialize($data, Event::class); return new self($data); } @@ -175,8 +176,10 @@ public static function fromJson(string $json): EventDescription public function toStream(): string { + $serialized = Serializer::serialize($this->event); + $serialized = - function_exists('igbinary_serialize') ? igbinary_serialize($this->event) : serialize($this->event); + function_exists('igbinary_serialize') ? igbinary_serialize($serialized) : serialize($serialized); $serialized = function_exists('gzencode') ? gzencode($serialized) : $serialized; $event = base64_encode($serialized); diff --git a/src/Glue/glue.php b/src/Glue/glue.php index d91b6daa..1c8e94f9 100644 --- a/src/Glue/glue.php +++ b/src/Glue/glue.php @@ -88,7 +88,7 @@ public function __construct(private DurableLogger $logger) $this->method = $_SERVER['HTTP_DPHP_FUNCTION']; try { $provenance = json_decode($_SERVER['HTTP_DPHP_PROVENANCE'] ?? 'null', true, 32, JSON_THROW_ON_ERROR); - if (!$provenance || $provenance === ['userId' => '', 'roles' => null]) { + if (! $provenance || $provenance === ['userId' => '', 'roles' => null]) { $this->provenance = null; } else { $provenance['roles'] ??= []; @@ -102,7 +102,7 @@ public function __construct(private DurableLogger $logger) $this->provenance = null; } - if (!file_exists($_SERVER['HTTP_DPHP_PAYLOAD'])) { + if (! file_exists($_SERVER['HTTP_DPHP_PAYLOAD'])) { throw new LogicException('Unable to load payload'); } @@ -192,7 +192,7 @@ public function outputEvent(EventDescription $event): void private function startOrchestration(): void { - if (!$this->target->toOrchestrationInstance()->executionId) { + if (! $this->target->toOrchestrationInstance()->executionId) { $this->target = StateId::fromInstance( OrchestrationInstance( $this->target->toOrchestrationInstance()->instanceId, @@ -204,8 +204,7 @@ private function startOrchestration(): void header('X-Id: ' . $this->target->id); $input = SerializedArray::import($this->payload['input'])->toArray(); - $event = - WithOrchestration::forInstance($this->target, StartExecution::asParent($input, []/* todo: scheduling */)); + $event = WithOrchestration::forInstance($this->target, StartExecution::asParent($input, []/* todo: scheduling */)); $this->outputEvent(new EventDescription($event)); $actualId = $this->target->toOrchestrationInstance(); diff --git a/src/State/EntityId.php b/src/State/EntityId.php index 7e7fe4f0..22886302 100644 --- a/src/State/EntityId.php +++ b/src/State/EntityId.php @@ -33,26 +33,17 @@ readonly class EntityId extends Record implements Stringable { public protected(set) string $name; + public protected(set) string $id; /** - * @param class-string $name - * @param string $id - * @return static + * @param class-string $name */ public static function from(string $name, string $id): static { return self::fromArgs(name: $name, id: $id); } - protected static function create(...$args): static - { - $obj = parent::create($args); - $obj->name = $args['name']; - $obj->id = $args['id']; - return $obj; - } - public function __toString(): string { return $this->name . ':' . $this->id; diff --git a/src/State/Exporter.php b/src/State/Exporter.php new file mode 100644 index 00000000..fc46cf80 --- /dev/null +++ b/src/State/Exporter.php @@ -0,0 +1,43 @@ +getMethod('getIdentity')->invoke($value); + + return parent::exportValue($serializer, $field, $id, $runningValue); + } + + public function canExport(Field $field, mixed $value, string $format): bool + { + return $value instanceof Record; + } + + public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed + { + $reflectedRecord = new ReflectionClass($field->phpType); + $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source['root'])); + + return $record; + } + + public function canImport(Field $field, string $format): bool + { + return is_a($field->phpType, Record::class, true); + } +} diff --git a/src/State/OrchestrationInstance.php b/src/State/OrchestrationInstance.php index a339dce6..15869daf 100644 --- a/src/State/OrchestrationInstance.php +++ b/src/State/OrchestrationInstance.php @@ -38,15 +38,6 @@ public static function from(string $instanceId, string $executionId): static return static::fromArgs(instanceId: $instanceId, executionId: $executionId); } - protected static function create(...$args): static - { - $obj = parent::create($args); - $obj->instanceId = $args['instanceId']; - $obj->executionId = $args['executionId']; - - return $obj; - } - public function __toString(): string { return "{$this->instanceId}:{$this->executionId}"; diff --git a/src/State/Serializer.php b/src/State/Serializer.php index 8587a780..0e58771f 100644 --- a/src/State/Serializer.php +++ b/src/State/Serializer.php @@ -38,11 +38,7 @@ public static function serialize(mixed $value, array $scopes = []): array return self::get()->serialize($value, 'array', scopes: $scopes); } if (is_array($value)) { - $result = []; - foreach ($value as $k => $v) { - $result[$k] = self::serialize($v, $scopes); - } - return $result; + return array_map(static fn($v) => self::serialize($v, $scopes), $value); } if (is_scalar($value) || $value === null) { return compact('value'); @@ -53,13 +49,13 @@ public static function serialize(mixed $value, array $scopes = []): array public static function get(): Serde { - return self::$serializer ??= new SerdeCommon(); + return self::$serializer ??= new SerdeCommon(handlers: [new Exporter()]); } /** * @template T - * @param array $value - * @param class-string $type + * + * @param class-string $type * @return T */ public static function deserialize(array $value, string $type): mixed diff --git a/tests/Unit/EventDescriptionTest.php b/tests/Unit/EventDescriptionTest.php index 0d04cd5c..be1d2579 100644 --- a/tests/Unit/EventDescriptionTest.php +++ b/tests/Unit/EventDescriptionTest.php @@ -279,6 +279,9 @@ public function __toString(): string $stream = json_decode($stream, true); $result = EventDescription::fromStream($stream['event']); + // hack around serialization of timestamps + $description = new EventDescription($event->with(timestamp: $result->event->timestamp)); + expect($result)->toEqual($description); }); diff --git a/tests/Unit/RecordTest.php b/tests/Unit/RecordTest.php new file mode 100644 index 00000000..04854d27 --- /dev/null +++ b/tests/Unit/RecordTest.php @@ -0,0 +1,22 @@ +toBe($record); +}); + +it('can serialize an orchestration id', function (): void { + $record = OrchestrationInstance('name', 'id'); + $result = Serializer::serialize($record); + $result = Serializer::deserialize($result, OrchestrationInstance::class); + expect($result)->toBe($record); +}); From b02b7b569c3b18e94f53d6a75786c57b889063aa Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 23:46:25 +0200 Subject: [PATCH 23/51] handle nulls Signed-off-by: Robert Landers --- src/State/Exporter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/State/Exporter.php b/src/State/Exporter.php index fc46cf80..cca127ba 100644 --- a/src/State/Exporter.php +++ b/src/State/Exporter.php @@ -31,6 +31,10 @@ public function canExport(Field $field, mixed $value, string $format): bool public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed { $reflectedRecord = new ReflectionClass($field->phpType); + if ($source === null || $source['root'] === null) { + return null; + } + $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source['root'])); return $record; From 4ace08d30a2b55390444b86310174816d8576c0e Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 23:52:53 +0200 Subject: [PATCH 24/51] output logging Signed-off-by: Robert Landers --- src/State/Exporter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/State/Exporter.php b/src/State/Exporter.php index cca127ba..b22ab86e 100644 --- a/src/State/Exporter.php +++ b/src/State/Exporter.php @@ -31,6 +31,7 @@ public function canExport(Field $field, mixed $value, string $format): bool public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed { $reflectedRecord = new ReflectionClass($field->phpType); + error_log(print_r($source, true)); if ($source === null || $source['root'] === null) { return null; } From 1fa9f8dd28eb967da8de4bd2ba8ee19a35c86ff1 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Mon, 28 Jul 2025 23:58:26 +0200 Subject: [PATCH 25/51] fix stateid Signed-off-by: Robert Landers --- src/State/Ids/StateId.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/State/Ids/StateId.php b/src/State/Ids/StateId.php index d7c1f8e5..a4839cce 100644 --- a/src/State/Ids/StateId.php +++ b/src/State/Ids/StateId.php @@ -70,16 +70,10 @@ public static function fromEntityId(EntityId $entityId): self return self::fromArgs(id: "entity:{$entityId}"); } - protected static function create(...$args): static - { - $obj = parent::create($args); - $obj->id = $args['id']; - return $obj; - } - public function toActivityId(): string { $parts = explode(':', $this->id, 3); + return match ($parts) { ['orchestration', $parts[1]] => throw new Exception('Cannot convert orchestration state to activity id'), ['activity', $parts[1]] => Uuid::fromString($parts[1])->toString(), @@ -95,6 +89,7 @@ public static function fromString(string $id): self public function toOrchestrationInstance(): OrchestrationInstance { $parts = explode(':', $this->id, 3); + return match ($parts) { ['activity', $parts[1]] => throw new Exception('Cannot convert activity state to orchestration instance'), ['orchestration', $parts[1], $parts[2]] => OrchestrationInstance($parts[1], $parts[2]), @@ -107,6 +102,7 @@ public function toOrchestrationInstance(): OrchestrationInstance public function toEntityId(): EntityId { $parts = explode(':', $this->id, 3); + return match ($parts) { ['activity', $parts[1]] => throw new Exception('Cannot convert activity state to entity id'), ['orchestration', $parts[1], $parts[2]] => throw new Exception( @@ -127,6 +123,7 @@ public function isActivityId(): bool public function getStateType(): string { $parts = explode(':', $this->id, 3); + return match ($parts) { ['activity', $parts[1]] => ActivityHistory::class, ['orchestration', $parts[1], $parts[2]] => OrchestrationHistory::class, @@ -134,7 +131,7 @@ public function getStateType(): string }; } - public function getPartitionKey(int $totalPartitions): int|null + public function getPartitionKey(int $totalPartitions): ?int { return match ($this->isPartitioned()) { true => crc32($this->id) % $totalPartitions, From 24ad367552fda5ff7acfa92cc2d62ad1d957ce6f Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 00:11:55 +0200 Subject: [PATCH 26/51] handle embedded fields better Signed-off-by: Robert Landers --- src/State/Exporter.php | 5 ++--- tests/Unit/RecordTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/State/Exporter.php b/src/State/Exporter.php index b22ab86e..a0a86098 100644 --- a/src/State/Exporter.php +++ b/src/State/Exporter.php @@ -31,12 +31,11 @@ public function canExport(Field $field, mixed $value, string $format): bool public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed { $reflectedRecord = new ReflectionClass($field->phpType); - error_log(print_r($source, true)); - if ($source === null || $source['root'] === null) { + if ($source === null || $source[$field->phpName] === null) { return null; } - $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source['root'])); + $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source[$field->phpName])); return $record; } diff --git a/tests/Unit/RecordTest.php b/tests/Unit/RecordTest.php index 04854d27..3b764434 100644 --- a/tests/Unit/RecordTest.php +++ b/tests/Unit/RecordTest.php @@ -1,6 +1,9 @@ toBe($record); }); + +it('can serialize an event', function (): void { + $entity = EntityId('name', 'id'); + $event = WithEntity::forInstance(StateId::fromEntityId($entity), RaiseEvent::forOperation('get', ['test' => 'test'])); + $result = Serializer::serialize($event); + $result = Serializer::deserialize($result, WithEntity::class); + expect($result->target)->toBe($event->target); +}); From a9751b3d8d8a61bd1cadc69df3178ec8cd0aeaa9 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 00:28:34 +0200 Subject: [PATCH 27/51] execute the signal better Signed-off-by: Robert Landers --- src/Proxy/SpyProxy.php | 24 +++++++++++++++++++++--- src/RemoteEntityClient.php | 6 +++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 216f0394..edb2f048 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -71,11 +71,12 @@ function (ReflectionParameter $param) { $value = '[]'; } $hookName = str_replace('$', '\$', $hookName); + return <<operation = "{$hookName}"; \$this->arguments = {$value}; - throw new \Exception('Not implemented'); + throw new \Bottledcode\DurablePhp\Proxy\SpyException('do not call outside of context'); } EOT; } @@ -84,7 +85,7 @@ function (ReflectionParameter $param) { public function {$name}({$params}){$return} { \$this->operation = "{$name}"; \$this->arguments = func_get_args(); - throw new \Exception('Not implemented'); + throw new \Bottledcode\DurablePhp\Proxy\SpyException('do not call outside of context'); } EOT; } @@ -102,7 +103,24 @@ protected function impureSignal(ReflectionMethod $method): string protected function preamble(ReflectionClass $class): string { return <<<'EOT' - public function __construct(private string|null &$operation = null, private array|null &$arguments = null) {} + private string|null $operation { + get => $this->op; + set { + if ($this->op !== null) { + throw new \LogicException('Can only send one signal at a time'); + } + } + } + private array|null $arguments { + get => $this->args; + set { + if ($this->args !== null) { + throw new \LogicException('Can only send one signal at a time'); + } + } + } + + public function __construct(private string|null &$op = null, private array|null &$args = null) {} EOT; } } diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 66e359f4..3f6cb4c5 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -26,6 +26,7 @@ use Amp\Http\Client\HttpClient; use Amp\Http\Client\Request; +use Bottledcode\DurablePhp\Proxy\SpyException; use Bottledcode\DurablePhp\Proxy\SpyProxy; use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; @@ -37,7 +38,6 @@ use Generator; use Override; use ReflectionFunction; -use Throwable; class RemoteEntityClient implements EntityClientInterface { @@ -84,8 +84,8 @@ public function signal(EntityId|string $entityId, Closure $signal): void try { $class = new $spy($operationName, $arguments); $signal($class); - } catch (Throwable) { - // spies always throw + } catch (SpyException) { + // we have completed the spy } $this->signalEntity( is_string($entityId) ? EntityId($interfaceName, $entityId) : $entityId, From 789cc3a6b92c74abc75119d5443da149d4ff51aa Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 11:01:37 +0200 Subject: [PATCH 28/51] add support for custom claims and roles Signed-off-by: Robert Landers --- cli/auth/keys.go | 15 +++++++++++---- cli/cli.go | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cli/auth/keys.go b/cli/auth/keys.go index fef356e0..d59b0b6b 100644 --- a/cli/auth/keys.go +++ b/cli/auth/keys.go @@ -109,13 +109,20 @@ func ExtractUser(r *http.Request, config *config.Config) (user *User, ok bool) { // The token is signed using the active secret key from the config. // The token will expire in 72 hours and is valid starting from 5 minutes ago. // Returns the signed token string or an error if the signing process fails. -func CreateUser(userId UserId, role []Role, config *config.Config) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ +func CreateUser(userId UserId, role []Role, claims map[string]string, config *config.Config) (string, error) { + claimMap := jwt.MapClaims{ "sub": userId, "exp": time.Now().Add(72 * time.Hour).Unix(), - "iat": time.Now().Add(-5 * time.Minute).Unix(), + "iat": time.Now(), + "nbf": time.Now().Add(-5 * time.Minute).Unix(), "roles": role, - }) + } + + for k, v := range claims { + claimMap[k] = v + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claimMap) key, err := getActiveKey(config) if err != nil { diff --git a/cli/cli.go b/cli/cli.go index 69c7d8e8..f7103031 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -529,6 +529,8 @@ func main() { createUser := cli.NewCommand("create-user", "Create a new user"). WithArg(cli.NewArg("id", "The user id to assign to the user").WithType(cli.TypeString)). WithOption(cli.NewOption("admin", "Create the user as an admin").WithType(cli.TypeBool)). + WithOption(cli.NewOption("roles", "Create with the roles").WithType(cli.TypeString).WithChar('r')). + WithOption(cli.NewOption("claims", "Create with the claims as key:value;key:value").WithType(cli.TypeString).WithChar('c')). WithAction(func(args []string, options map[string]string) int { cfg, err := config.GetProjectConfig() if err != nil { @@ -540,7 +542,22 @@ func main() { rol = append(rol, "admin") } - user, err := auth.CreateUser(auth.UserId(args[0]), rol, cfg) + roles := strings.Split(options["roles"], ",") + for _, role := range roles { + rol = append(rol, auth.Role(role)) + } + + claims := strings.Split(options["claims"], ";") + extraClaims := make(map[string]string) + for _, claim := range claims { + kv := strings.Split(claim, ":") + if len(kv) != 2 { + panic(fmt.Errorf("invalid claim: %s", claim)) + } + extraClaims[kv[0]] = kv[1] + } + + user, err := auth.CreateUser(auth.UserId(args[0]), rol, extraClaims, cfg) if err != nil { return 1 } From bc729d36a4b423c9e86aaffbb7ffbbf44fc29187 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 11:01:54 +0200 Subject: [PATCH 29/51] add spy exception Signed-off-by: Robert Landers --- src/Proxy/SpyException.php | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/Proxy/SpyException.php diff --git a/src/Proxy/SpyException.php b/src/Proxy/SpyException.php new file mode 100644 index 00000000..574e808b --- /dev/null +++ b/src/Proxy/SpyException.php @@ -0,0 +1,5 @@ + Date: Tue, 29 Jul 2025 13:44:41 +0200 Subject: [PATCH 30/51] trim spaces Signed-off-by: Robert Landers --- cli/auth/keys.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/auth/keys.go b/cli/auth/keys.go index d59b0b6b..2e5e4cd0 100644 --- a/cli/auth/keys.go +++ b/cli/auth/keys.go @@ -119,6 +119,8 @@ func CreateUser(userId UserId, role []Role, claims map[string]string, config *co } for k, v := range claims { + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) claimMap[k] = v } From 6b19f592eb227ebca6b9b93d57eaeb62b233ed39 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 14:18:35 +0200 Subject: [PATCH 31/51] allow sharing ownership via api Signed-off-by: Robert Landers --- cli/auth/keys.go | 5 +++++ cli/lib/api.go | 40 +++++++++++++++++++++++++++++++++++ src/DurableClient.php | 6 ++++++ src/EntityClientInterface.php | 3 +++ src/RemoteEntityClient.php | 15 +++++++++++++ 5 files changed, 69 insertions(+) diff --git a/cli/auth/keys.go b/cli/auth/keys.go index 2e5e4cd0..b114ddf6 100644 --- a/cli/auth/keys.go +++ b/cli/auth/keys.go @@ -35,6 +35,11 @@ func DecorateContextWithUser(ctx context.Context, user *User) context.Context { return context.WithValue(ctx, appcontext.CurrentUserKey, user) } +func GetUserFromContext(ctx context.Context) *User { + user, _ := ctx.Value(appcontext.CurrentUserKey).(*User) + return user +} + // ExtractUser extracts user information from the Authorization token in the HTTP request header. // It returns the user and a boolean indicating if the extraction was successful. // diff --git a/cli/lib/api.go b/cli/lib/api.go index b6fd6399..dcae1e61 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -315,6 +315,46 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } } + // POST /resource/{id}/share: share ownership of the resource with another user + r.HandleFunc("/resource/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + if request.Method != "POST" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + + vars := mux.Vars(request) + id := &glue.StateId{ + Id: strings.TrimSpace(vars["id"]), + } + + // verify the user is authorized to access the resource + ctx, done := authorize(writer, request, config, ctx, rm, id, logger, true, auth.Owner) + if done { + return + } + + r, err := rm.DiscoverResource(ctx, id, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "Not Found", http.StatusNotFound) + } + + newUser := strings.TrimSpace(vars["userid"]) + + err = r.ShareOwnership(auth.UserId(newUser), auth.GetUserFromContext(ctx), true) + if err != nil { + logger.Error("Failed to share ownership", zap.Error(err)) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + } + }) + // GET /entity/{name}/{id} // get an entity state and status // PUT /entity/{name}/{id} diff --git a/src/DurableClient.php b/src/DurableClient.php index 61ada64f..db945d9c 100644 --- a/src/DurableClient.php +++ b/src/DurableClient.php @@ -1,4 +1,5 @@ entityClient->deleteEntity($entityId); } + + public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void + { + $this->entityClient->shareOwnership($resource, $with); + } } diff --git a/src/EntityClientInterface.php b/src/EntityClientInterface.php index 52eaf7c1..7ab8d17b 100644 --- a/src/EntityClientInterface.php +++ b/src/EntityClientInterface.php @@ -27,6 +27,7 @@ use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; +use Bottledcode\DurablePhp\State\OrchestrationInstance; use Closure; use DateTimeImmutable; use Generator; @@ -80,4 +81,6 @@ public function getEntitySnapshot(EntityId $entityId): ?EntityState; * Deletes an entity */ public function deleteEntity(EntityId $entityId): void; + + public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void; } diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 3f6cb4c5..30dbc2e8 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -31,6 +31,8 @@ use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; +use Bottledcode\DurablePhp\State\Ids\StateId; +use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\State\Serializer; use Closure; use DateTimeImmutable; @@ -158,4 +160,17 @@ public function deleteEntity(EntityId $entityId): void throw new Exception('Failed to delete entity'); } } + + public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void + { + $id = $resource instanceof EntityId ? StateId::fromEntityId($resource) : StateId::fromInstance($resource); + $req = new Request("{$this->apiHost}/resource/{$id}/share/{$with}", 'POST'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to share ownership'); + } + } } From 5ffaeb66edcbacdf35b4b4514f416cd44a46a9f3 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 14:30:08 +0200 Subject: [PATCH 32/51] better handling of spies Signed-off-by: Robert Landers --- cli/cli.go | 14 ++++++++------ src/RemoteEntityClient.php | 9 +++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index f7103031..6807ad73 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -547,14 +547,16 @@ func main() { rol = append(rol, auth.Role(role)) } - claims := strings.Split(options["claims"], ";") extraClaims := make(map[string]string) - for _, claim := range claims { - kv := strings.Split(claim, ":") - if len(kv) != 2 { - panic(fmt.Errorf("invalid claim: %s", claim)) + if options["claims"] != "" { + claims := strings.Split(options["claims"], ";") + for _, claim := range claims { + kv := strings.Split(claim, ":") + if len(kv) != 2 { + panic(fmt.Errorf("invalid claim: %s", claim)) + } + extraClaims[kv[0]] = kv[1] } - extraClaims[kv[0]] = kv[1] } user, err := auth.CreateUser(auth.UserId(args[0]), rol, extraClaims, cfg) diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 30dbc2e8..c20b10ba 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -81,14 +81,19 @@ public function signal(EntityId|string $entityId, Closure $signal): void throw new Exception("Interface {$interfaceName} does not exist"); } $spy = $this->spyProxy->define($interfaceName); - $operationName = ''; - $arguments = []; + $operationName = null; + $arguments = null; try { $class = new $spy($operationName, $arguments); $signal($class); } catch (SpyException) { // we have completed the spy } + + if ($operationName === null || $arguments === null) { + return; + } + $this->signalEntity( is_string($entityId) ? EntityId($interfaceName, $entityId) : $entityId, $operationName, From acadbd81013ce704979a112de78bb35dacfb8e44 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 14:37:56 +0200 Subject: [PATCH 33/51] fix setting operations and args Signed-off-by: Robert Landers --- src/Proxy/SpyProxy.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index edb2f048..58b68475 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -109,6 +109,7 @@ protected function preamble(ReflectionClass $class): string if ($this->op !== null) { throw new \LogicException('Can only send one signal at a time'); } + $this->op = $value; } } private array|null $arguments { @@ -117,6 +118,7 @@ protected function preamble(ReflectionClass $class): string if ($this->args !== null) { throw new \LogicException('Can only send one signal at a time'); } + $this->args = $value; } } From 9f3b9fd716da35b2faac730dea56246c2af68c8b Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 14:52:40 +0200 Subject: [PATCH 34/51] fix sharing ownership Signed-off-by: Robert Landers --- cli/lib/api.go | 14 ++++++++------ src/DurableClient.php | 4 ++-- src/EntityClientInterface.php | 3 +-- src/RemoteEntityClient.php | 7 ++----- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/cli/lib/api.go b/cli/lib/api.go index dcae1e61..e14b5b24 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -315,8 +315,8 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } } - // POST /resource/{id}/share: share ownership of the resource with another user - r.HandleFunc("/resource/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) { + // POST /entity/{name}/{id}/share/{userid}: share ownership of the resource with another user + r.HandleFunc("/entity/{name}/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) { if stop := handleCors(writer, request); stop { return } @@ -330,17 +330,19 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logRequest(logger, request, ctx) vars := mux.Vars(request) - id := &glue.StateId{ - Id: strings.TrimSpace(vars["id"]), + id := &glue.EntityId{ + Name: strings.TrimSpace(vars["name"]), + Id: strings.TrimSpace(vars["id"]), } + stateId := id.ToStateId() // verify the user is authorized to access the resource - ctx, done := authorize(writer, request, config, ctx, rm, id, logger, true, auth.Owner) + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.Owner) if done { return } - r, err := rm.DiscoverResource(ctx, id, logger, true) + r, err := rm.DiscoverResource(ctx, stateId, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) diff --git a/src/DurableClient.php b/src/DurableClient.php index db945d9c..69725e30 100644 --- a/src/DurableClient.php +++ b/src/DurableClient.php @@ -143,8 +143,8 @@ public function deleteEntity(EntityId $entityId): void $this->entityClient->deleteEntity($entityId); } - public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void + public function shareEntityOwnership(EntityId $id, string $with): void { - $this->entityClient->shareOwnership($resource, $with); + $this->entityClient->shareEntityOwnership($id, $with); } } diff --git a/src/EntityClientInterface.php b/src/EntityClientInterface.php index 7ab8d17b..d6791b98 100644 --- a/src/EntityClientInterface.php +++ b/src/EntityClientInterface.php @@ -27,7 +27,6 @@ use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Closure; use DateTimeImmutable; use Generator; @@ -82,5 +81,5 @@ public function getEntitySnapshot(EntityId $entityId): ?EntityState; */ public function deleteEntity(EntityId $entityId): void; - public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void; + public function shareEntityOwnership(EntityId $id, string $with): void; } diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index c20b10ba..405a4adf 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -31,8 +31,6 @@ use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; -use Bottledcode\DurablePhp\State\Ids\StateId; -use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\State\Serializer; use Closure; use DateTimeImmutable; @@ -166,10 +164,9 @@ public function deleteEntity(EntityId $entityId): void } } - public function shareOwnership(EntityId|OrchestrationInstance $resource, string $with): void + public function shareEntityOwnership(EntityId $id, string $with): void { - $id = $resource instanceof EntityId ? StateId::fromEntityId($resource) : StateId::fromInstance($resource); - $req = new Request("{$this->apiHost}/resource/{$id}/share/{$with}", 'POST'); + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/share/{$with}", 'POST'); if ($this->userToken) { $req->setHeader('Authorization', 'Bearer ' . $this->userToken); } From cba9f5d21031727e4a40e1df99420536d60a9d78 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 15:21:38 +0200 Subject: [PATCH 35/51] grant shares to users Signed-off-by: Robert Landers --- cli/lib/api.go | 75 ++++++++++++++++++++++++++++++++++- src/DurableClient.php | 11 +++++ src/EntityClientInterface.php | 5 +++ src/RemoteEntityClient.php | 27 ++++++++++++- 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/cli/lib/api.go b/cli/lib/api.go index e14b5b24..f7c5bdd8 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -315,13 +315,13 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } } - // POST /entity/{name}/{id}/share/{userid}: share ownership of the resource with another user + // PUT /entity/{name}/{id}/share/{userid}: share ownership of the resource with another user r.HandleFunc("/entity/{name}/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) { if stop := handleCors(writer, request); stop { return } - if request.Method != "POST" { + if request.Method != "PUT" { http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) return } @@ -355,6 +355,77 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logger.Error("Failed to share ownership", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + } + + logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser)) + http.Error(writer, "", http.StatusOK) + }) + + // PUT /entity/{name}/{id}/grant/{user}/{operation} + r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}/{operation}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + vars := mux.Vars(request) + id := &glue.EntityId{ + Name: strings.TrimSpace(vars["name"]), + Id: strings.TrimSpace(vars["id"]), + } + stateId := id.ToStateId() + + operation := auth.Owner + switch strings.ToLower(vars["operation"]) { + case "signal": + operation = auth.Signal + break + case "completion": + operation = auth.Completion + break + case "output": + operation = auth.Output + case "call": + operation = auth.Call + case "lock": + operation = auth.Lock + case "sharePlus": + operation = auth.SharePlus + case "shareMinus": + operation = auth.ShareMinus + default: + http.Error(writer, "", http.StatusBadRequest) + return + } + + r, err := rm.DiscoverResource(ctx, stateId, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "", http.StatusNotFound) + } + + switch vars["type"] { + case "user": + err = r.GrantUser(auth.UserId(vars["user"]), operation, ctx) + case "role": + err = r.GrantRole(auth.Role(vars["user"]), operation, ctx) + } + if err != nil { + logger.Error("Failed to grant resource", zap.Error(err)) + http.Error(writer, "", http.StatusForbidden) + } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "", http.StatusInternalServerError) + } + + http.Error(writer, "", http.StatusOK) }) // GET /entity/{name}/{id} diff --git a/src/DurableClient.php b/src/DurableClient.php index 69725e30..e1a279bc 100644 --- a/src/DurableClient.php +++ b/src/DurableClient.php @@ -25,6 +25,7 @@ namespace Bottledcode\DurablePhp; use Amp\Http\Client\HttpClientBuilder; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; @@ -147,4 +148,14 @@ public function shareEntityOwnership(EntityId $id, string $with): void { $this->entityClient->shareEntityOwnership($id, $with); } + + public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void + { + $this->entityClient->grantEntityAccessToUser($id, $user, $operation); + } + + public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void + { + $this->entityClient->grantEntityAccessToRole($id, $role, $operation); + } } diff --git a/src/EntityClientInterface.php b/src/EntityClientInterface.php index d6791b98..bbbb3f23 100644 --- a/src/EntityClientInterface.php +++ b/src/EntityClientInterface.php @@ -24,6 +24,7 @@ namespace Bottledcode\DurablePhp; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\Search\EntityFilter; use Bottledcode\DurablePhp\State\EntityId; use Bottledcode\DurablePhp\State\EntityState; @@ -82,4 +83,8 @@ public function getEntitySnapshot(EntityId $entityId): ?EntityState; public function deleteEntity(EntityId $entityId): void; public function shareEntityOwnership(EntityId $id, string $with): void; + + public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void; + + public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void; } diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 405a4adf..1fec8e3c 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -26,6 +26,7 @@ use Amp\Http\Client\HttpClient; use Amp\Http\Client\Request; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\Proxy\SpyException; use Bottledcode\DurablePhp\Proxy\SpyProxy; use Bottledcode\DurablePhp\Search\EntityFilter; @@ -166,7 +167,7 @@ public function deleteEntity(EntityId $entityId): void public function shareEntityOwnership(EntityId $id, string $with): void { - $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/share/{$with}", 'POST'); + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/share/{$with}", 'PUT'); if ($this->userToken) { $req->setHeader('Authorization', 'Bearer ' . $this->userToken); } @@ -175,4 +176,28 @@ public function shareEntityOwnership(EntityId $id, string $with): void throw new Exception('Failed to share ownership'); } } + + public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void + { + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/user/{$user}/{$operation->value}", 'PUT'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } + + public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void + { + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/role/{$role}/{$operation->value}", 'PUT'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } } From 6c492ce2e9822e408fd4a30bc14f7512d954b82b Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 16:12:54 +0200 Subject: [PATCH 36/51] handle orchestrations and fix bug Signed-off-by: Robert Landers --- cli/lib/api.go | 211 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/cli/lib/api.go b/cli/lib/api.go index f7c5bdd8..1ff86548 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -372,6 +372,11 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } + if request.Method != "PUT" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + vars := mux.Vars(request) id := &glue.EntityId{ Name: strings.TrimSpace(vars["name"]), @@ -428,6 +433,50 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po http.Error(writer, "", http.StatusOK) }) + // DELETE /entity/{name}/{id}/grant/{type}/{user} + r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + if request.Method != "DELETE" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(request) + id := &glue.EntityId{ + Name: strings.TrimSpace(vars["name"]), + Id: strings.TrimSpace(vars["id"]), + } + stateId := id.ToStateId() + + r, err := rm.DiscoverResource(ctx, stateId, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "", http.StatusNotFound) + } + + switch vars["type"] { + case "user": + err = r.RevokeUser(auth.UserId(vars["user"]), ctx) + case "role": + err = r.RevokeRole(auth.Role(vars["user"]), ctx) + } + if err != nil { + logger.Error("Failed to revoke resource", zap.Error(err)) + http.Error(writer, "", http.StatusForbidden) + } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "", http.StatusInternalServerError) + } + + http.Error(writer, "", http.StatusOK) + }) + // GET /entity/{name}/{id} // get an entity state and status // PUT /entity/{name}/{id} @@ -569,6 +618,168 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po processReq(ctx, writer, request, id.ToStateId(), glue.StartOrchestration, make(http.Header)) }) + // PUT /orchestration/{name}/{id}/share/{userid}: share ownership of the resource with another user + r.HandleFunc("/orchestration/{name}/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + if request.Method != "PUT" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + + vars := mux.Vars(request) + id := &glue.OrchestrationId{ + InstanceId: strings.TrimSpace(vars["name"]), + ExecutionId: strings.TrimSpace(vars["id"]), + } + stateId := id.ToStateId() + + // verify the user is authorized to access the resource + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.Owner) + if done { + return + } + + r, err := rm.DiscoverResource(ctx, stateId, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "Not Found", http.StatusNotFound) + } + + newUser := strings.TrimSpace(vars["userid"]) + + err = r.ShareOwnership(auth.UserId(newUser), auth.GetUserFromContext(ctx), true) + if err != nil { + logger.Error("Failed to share ownership", zap.Error(err)) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + } + + logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser)) + http.Error(writer, "", http.StatusOK) + }) + + // PUT /orchestration/{name}/{id}/grant/{user}/{operation} + r.HandleFunc("/orchestration/{name}/{id}/grant/{type}/{user}/{operation}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + if request.Method != "PUT" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(request) + id := &glue.OrchestrationId{ + InstanceId: strings.TrimSpace(vars["name"]), + ExecutionId: strings.TrimSpace(vars["id"]), + } + stateId := id.ToStateId() + + operation := auth.Owner + switch strings.ToLower(vars["operation"]) { + case "signal": + operation = auth.Signal + break + case "completion": + operation = auth.Completion + break + case "output": + operation = auth.Output + case "call": + operation = auth.Call + case "lock": + operation = auth.Lock + case "sharePlus": + operation = auth.SharePlus + case "shareMinus": + operation = auth.ShareMinus + default: + http.Error(writer, "", http.StatusBadRequest) + return + } + + r, err := rm.DiscoverResource(ctx, stateId, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "", http.StatusNotFound) + } + + switch vars["type"] { + case "user": + err = r.GrantUser(auth.UserId(vars["user"]), operation, ctx) + case "role": + err = r.GrantRole(auth.Role(vars["user"]), operation, ctx) + } + if err != nil { + logger.Error("Failed to grant resource", zap.Error(err)) + http.Error(writer, "", http.StatusForbidden) + } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "", http.StatusInternalServerError) + } + + http.Error(writer, "", http.StatusOK) + }) + + // DELETE /orchestration/{name}/{id}/grant/{type}/{user} + r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) { + if stop := handleCors(writer, request); stop { + return + } + + if request.Method != "DELETE" { + http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(request) + id := &glue.OrchestrationId{ + InstanceId: strings.TrimSpace(vars["name"]), + ExecutionId: strings.TrimSpace(vars["id"]), + } + stateId := id.ToStateId() + + r, err := rm.DiscoverResource(ctx, stateId, logger, true) + if err != nil { + logger.Error("Failed to discover resource", zap.Error(err)) + http.Error(writer, "", http.StatusNotFound) + } + + switch vars["type"] { + case "user": + err = r.RevokeUser(auth.UserId(vars["user"]), ctx) + case "role": + err = r.RevokeRole(auth.Role(vars["user"]), ctx) + } + if err != nil { + logger.Error("Failed to revoke resource", zap.Error(err)) + http.Error(writer, "", http.StatusForbidden) + } + + err = r.Update(ctx, logger) + if err != nil { + logger.Error("Failed to update resource", zap.Error(err)) + http.Error(writer, "", http.StatusInternalServerError) + } + + http.Error(writer, "", http.StatusOK) + }) + // PUT /orchestration/{name}/{id} // start a new orchestration // GET /orchestration/{name}/{id}?wait=?? From b6d0f5bcb6a0ed8d78d59a1ff8d8a9fc6d978bae Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 16:23:09 +0200 Subject: [PATCH 37/51] add clients Signed-off-by: Robert Landers --- src/DurableClient.php | 35 ++++++++++++++++ src/EntityClientInterface.php | 4 ++ src/OrchestrationClientInterface.php | 16 ++++++- src/RemoteEntityClient.php | 24 +++++++++++ src/RemoteOrchestrationClient.php | 63 +++++++++++++++++++++++++++- 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/DurableClient.php b/src/DurableClient.php index e1a279bc..df594a24 100644 --- a/src/DurableClient.php +++ b/src/DurableClient.php @@ -158,4 +158,39 @@ public function grantEntityAccessToRole(EntityId $id, string $role, Operation $o { $this->entityClient->grantEntityAccessToRole($id, $role, $operation); } + + public function revokeEntityAccessToUser(EntityId $id, string $user): void + { + $this->entityClient->revokeEntityAccessToUser($id, $user); + } + + public function revokeEntityAccessToRole(EntityId $id, string $role): void + { + $this->entityClient->revokeEntityAccessToRole($id, $role); + } + + public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void + { + $this->orchestrationClient->shareOrchestrationOwnership($id, $with); + } + + public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void + { + $this->orchestrationClient->grantOrchestrationAccessToUser($id, $user, $operation); + } + + public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void + { + $this->orchestrationClient->grantOrchestrationAccessToRole($id, $role, $operation); + } + + public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void + { + $this->orchestrationClient->revokeOrchestrationAccessToUser($id, $user); + } + + public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void + { + $this->orchestrationClient->revokeOrchestrationAccessToRole($id, $role); + } } diff --git a/src/EntityClientInterface.php b/src/EntityClientInterface.php index bbbb3f23..e2e6e5b2 100644 --- a/src/EntityClientInterface.php +++ b/src/EntityClientInterface.php @@ -87,4 +87,8 @@ public function shareEntityOwnership(EntityId $id, string $with): void; public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void; public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void; + + public function revokeEntityAccessToUser(EntityId $id, string $user): void; + + public function revokeEntityAccessToRole(EntityId $id, string $role): void; } diff --git a/src/OrchestrationClientInterface.php b/src/OrchestrationClientInterface.php index 2a35039d..69f490c6 100644 --- a/src/OrchestrationClientInterface.php +++ b/src/OrchestrationClientInterface.php @@ -24,8 +24,10 @@ namespace Bottledcode\DurablePhp; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\State\OrchestrationInstance; use Bottledcode\DurablePhp\State\Status; +use Generator; interface OrchestrationClientInterface { @@ -33,7 +35,7 @@ public function withAuth(string $token): void; public function getStatus(OrchestrationInstance $instance): Status; - public function listInstances(/* todo */): \Generator; + public function listInstances(/* todo */): Generator; public function purge(OrchestrationInstance $instance): void; @@ -43,11 +45,21 @@ public function restart(OrchestrationInstance $instance): void; public function resume(OrchestrationInstance $instance, string $reason): void; - public function startNew(string $name, array $args = [], string|null $id = null): OrchestrationInstance; + public function startNew(string $name, array $args = [], ?string $id = null): OrchestrationInstance; public function suspend(OrchestrationInstance $instance, string $reason): void; public function terminate(OrchestrationInstance $instance, string $reason): void; public function waitForCompletion(OrchestrationInstance $instance): void; + + public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void; + + public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void; + + public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void; + + public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void; + + public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void; } diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php index 1fec8e3c..8b2dc8a0 100644 --- a/src/RemoteEntityClient.php +++ b/src/RemoteEntityClient.php @@ -200,4 +200,28 @@ public function grantEntityAccessToRole(EntityId $id, string $role, Operation $o throw new Exception('Failed to grant access'); } } + + public function revokeEntityAccessToUser(EntityId $id, string $user): void + { + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/user/{$user}", 'DELETE'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } + + public function revokeEntityAccessToRole(EntityId $id, string $role): void + { + $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/role/{$role}", 'DELETE'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } } diff --git a/src/RemoteOrchestrationClient.php b/src/RemoteOrchestrationClient.php index 1cba3967..1be81766 100644 --- a/src/RemoteOrchestrationClient.php +++ b/src/RemoteOrchestrationClient.php @@ -27,6 +27,7 @@ use Amp\Http\Client\HttpClient; use Amp\Http\Client\Request; use Amp\Http\Client\SocketException; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\Proxy\SpyProxy; use Bottledcode\DurablePhp\State\Ids\StateId; use Bottledcode\DurablePhp\State\OrchestrationInstance; @@ -147,7 +148,7 @@ public function startNew(string $name, array $args = [], ?string $id = null): Or throw new Exception($result->getBody()->buffer()); } - return (new StateId($result->getHeader('X-Id')))->toOrchestrationInstance(); + return StateId::fromString($result->getHeader('X-Id'))->toOrchestrationInstance(); } #[Override] @@ -192,4 +193,64 @@ public function withAuth(string $token): void { $this->userToken = $token; } + + public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void + { + $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/share/{$with}", 'PUT'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to share ownership'); + } + } + + public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void + { + $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/user/{$user}/{$operation->value}", 'PUT'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } + + public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void + { + $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/role/{$role}/{$operation->value}", 'PUT'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } + + public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void + { + $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/user/{$user}", 'DELETE'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } + + public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void + { + $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/role/{$role}", 'DELETE'); + if ($this->userToken) { + $req->setHeader('Authorization', 'Bearer ' . $this->userToken); + } + $result = $this->client->request($req); + if ($result->getStatus() !== 200) { + throw new Exception('Failed to grant access'); + } + } } From 932d78439b7c8450a579a9ff7521bea74bf7ead2 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 16:34:34 +0200 Subject: [PATCH 38/51] fix security issue Signed-off-by: Robert Landers --- cli/lib/api.go | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/cli/lib/api.go b/cli/lib/api.go index 1ff86548..af3f4621 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -346,6 +346,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) + return } newUser := strings.TrimSpace(vars["userid"]) @@ -354,12 +355,14 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to share ownership", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + return } logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser)) @@ -384,6 +387,11 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } stateId := id.ToStateId() + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.SharePlus) + if done { + return + } + operation := auth.Owner switch strings.ToLower(vars["operation"]) { case "signal": @@ -411,6 +419,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) + return } switch vars["type"] { @@ -422,12 +431,14 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to grant resource", zap.Error(err)) http.Error(writer, "", http.StatusForbidden) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "", http.StatusInternalServerError) + return } http.Error(writer, "", http.StatusOK) @@ -451,10 +462,16 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } stateId := id.ToStateId() + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.ShareMinus) + if done { + return + } + r, err := rm.DiscoverResource(ctx, stateId, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) + return } switch vars["type"] { @@ -466,12 +483,14 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to revoke resource", zap.Error(err)) http.Error(writer, "", http.StatusForbidden) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "", http.StatusInternalServerError) + return } http.Error(writer, "", http.StatusOK) @@ -649,6 +668,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "Not Found", http.StatusNotFound) + return } newUser := strings.TrimSpace(vars["userid"]) @@ -657,12 +677,14 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to share ownership", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + return } logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser)) @@ -687,6 +709,11 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } stateId := id.ToStateId() + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.SharePlus) + if done { + return + } + operation := auth.Owner switch strings.ToLower(vars["operation"]) { case "signal": @@ -714,6 +741,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) + return } switch vars["type"] { @@ -725,19 +753,21 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to grant resource", zap.Error(err)) http.Error(writer, "", http.StatusForbidden) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "", http.StatusInternalServerError) + return } http.Error(writer, "", http.StatusOK) }) // DELETE /orchestration/{name}/{id}/grant/{type}/{user} - r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) { + r.HandleFunc("/orchestration/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) { if stop := handleCors(writer, request); stop { return } @@ -754,10 +784,16 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } stateId := id.ToStateId() + ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.ShareMinus) + if done { + return + } + r, err := rm.DiscoverResource(ctx, stateId, logger, true) if err != nil { logger.Error("Failed to discover resource", zap.Error(err)) http.Error(writer, "", http.StatusNotFound) + return } switch vars["type"] { @@ -769,12 +805,14 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po if err != nil { logger.Error("Failed to revoke resource", zap.Error(err)) http.Error(writer, "", http.StatusForbidden) + return } err = r.Update(ctx, logger) if err != nil { logger.Error("Failed to update resource", zap.Error(err)) http.Error(writer, "", http.StatusInternalServerError) + return } http.Error(writer, "", http.StatusOK) From 5e8cb01bace5e19e2ec8170f105b5e38db677d4b Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Tue, 29 Jul 2025 22:08:50 +0200 Subject: [PATCH 39/51] add an auth context Signed-off-by: Robert Landers --- cli/auth/resourceManager.go | 43 +++++++++++++++ cli/glue/glue.go | 13 +++++ composer.json | 3 +- src/Contexts/AuthContext.php | 36 +++++++++++++ src/Contexts/AuthContext/Share.php | 27 ++++++++++ src/Contexts/AuthContext/Share/Owner.php | 29 ++++++++++ src/Contexts/AuthContext/Share/Role.php | 7 +++ src/Contexts/AuthContext/Share/User.php | 7 +++ src/Contexts/AuthContext/functions.php | 30 +++++++++++ src/EntityContext.php | 67 ++++++++++++++++++++++++ src/EntityContextInterface.php | 13 +++++ src/Events/RevokeRole.php | 7 +-- src/Events/RevokeUser.php | 6 +-- 13 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 src/Contexts/AuthContext.php create mode 100644 src/Contexts/AuthContext/Share.php create mode 100644 src/Contexts/AuthContext/Share/Owner.php create mode 100644 src/Contexts/AuthContext/Share/Role.php create mode 100644 src/Contexts/AuthContext/Share/User.php create mode 100644 src/Contexts/AuthContext/functions.php diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go index 1d5989cf..ed06ce07 100644 --- a/cli/auth/resourceManager.go +++ b/cli/auth/resourceManager.go @@ -4,10 +4,12 @@ import ( "context" "durable_php/appcontext" "durable_php/glue" + "encoding/json" "github.com/modern-go/concurrent" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "go.uber.org/zap" + "maps" "time" ) @@ -87,6 +89,47 @@ func (r *ResourceManager) DiscoverResource(ctx context.Context, id *glue.StateId return resource, nil } +func (r *ResourceManager) ToAuthContext(ctx context.Context, resource *Resource) ([]byte, error) { + var owners []map[string]interface{} + + for o, _ := range resource.Owners { + owners = append(owners, map[string]interface{}{ + "shareType": "owner", + "subject": string(o), + "allowed": []string{string(Owner)}, + }) + } + + var shares []map[string]interface{} + + for _, s := range resource.Shares { + if u, ok := s.(*UserShare); ok { + shares = append(shares, map[string]interface{}{ + "shareType": "user", + "subject": string(u.UserId), + "allowed": maps.Keys(u.AllowedOperations), + }) + } + if r, ok := s.(*RoleShare); ok { + shares = append(shares, map[string]interface{}{ + "shareType": "role", + "subject": string(r.Role), + "allowed": maps.Keys(r.AllowedOperations), + }) + } + } + + c := map[string]interface{}{ + "contextId": map[string]string{ + "id": resource.id.String(), + }, + "owners": owners, + "shares": shares, + } + + return json.Marshal(c) +} + // ScheduleDelete is a method of the ResourceManager struct that is responsible for scheduling the deletion of a // resource based on the provided context, resource, and time. It deletes the resource from the key-value store and // publishes a delete message to NATS JetStream with a delay specified by the provided time. The resource is identified diff --git a/cli/glue/glue.go b/cli/glue/glue.go index 0ac1fc56..4d7e2ca3 100644 --- a/cli/glue/glue.go +++ b/cli/glue/glue.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "durable_php/appcontext" + "durable_php/auth" "encoding/json" "fmt" "github.com/dunglas/frankenphp" @@ -11,6 +12,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "go.uber.org/zap" "io" + "maps" "net/http" "net/url" "os" @@ -126,6 +128,17 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log headers.Add("DPHP_FUNCTION", string(g.function)) headers.Add("DPHP_PAYLOAD", g.payload) + rm := auth.GetResourceManager(ctx, stream) + res, err := rm.DiscoverResource(ctx, id, logger, true) + if err != nil { + logger.Error("DiscoverResource", zap.Error(err)) + panic(err) + } + if res != nil { + ac, _ := rm.ToAuthContext(ctx, res) + headers.Add("DPHP_AUTH_CONTEXT", string(ac)) + } + provenance := ctx.Value(appcontext.CurrentUserKey) if provenance != nil { provenanceJson, err := json.Marshal(provenance) diff --git a/composer.json b/composer.json index 754f88a0..bdc81d84 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "Bottledcode\\DurablePhp\\": "src/" }, "files": [ - "src/functions.php" + "src/functions.php", + "src/Contexts/AuthContext/functions.php" ] }, "autoload-dev": { diff --git a/src/Contexts/AuthContext.php b/src/Contexts/AuthContext.php new file mode 100644 index 00000000..a3317ff5 --- /dev/null +++ b/src/Contexts/AuthContext.php @@ -0,0 +1,36 @@ + + */ + #[SequenceField(arrayType: Owner::class)] + public array $owners; + + /** + * @var array + */ + #[SequenceField(arrayType: Share::class)] + public array $shares; + + public static function fromCurrentContext(): ?AuthContext + { + if (isset($_SERVER['HTTP_DPHP_AUTH_CONTEXT'])) { + return Serializer::deserialize($_SERVER['HTTP_DPHP_AUTH_CONTEXT'], self::class); + } + + return null; + } +} diff --git a/src/Contexts/AuthContext/Share.php b/src/Contexts/AuthContext/Share.php new file mode 100644 index 00000000..54dfd993 --- /dev/null +++ b/src/Contexts/AuthContext/Share.php @@ -0,0 +1,27 @@ + Owner::class, + 'role' => Role::class, + 'user' => User::class, +])] +abstract readonly class Share extends Record +{ + public string $subject; + + /** + * @var array + */ + #[SequenceField(arrayType: Operation::class)] + public array $allowed; +} diff --git a/src/Contexts/AuthContext/Share/Owner.php b/src/Contexts/AuthContext/Share/Owner.php new file mode 100644 index 00000000..3b3ec9df --- /dev/null +++ b/src/Contexts/AuthContext/Share/Owner.php @@ -0,0 +1,29 @@ +getMethod('fromArgs')->invoke(null, subject: $subject, allowed: [Operation::Owner]); +} + +function Role(string $subject, Operation ...$allowed): Role +{ + $ref = new ReflectionClass(Role::class); + + return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed); +} + +function User(string $subject, Operation ...$allowed): User +{ + $ref = new ReflectionClass(User::class); + + return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed); +} diff --git a/src/EntityContext.php b/src/EntityContext.php index fd55ba42..fae80d18 100644 --- a/src/EntityContext.php +++ b/src/EntityContext.php @@ -24,7 +24,14 @@ namespace Bottledcode\DurablePhp; +use Bottledcode\DurablePhp\Events\GiveOwnership; use Bottledcode\DurablePhp\Events\RaiseEvent; +use Bottledcode\DurablePhp\Events\RevokeRole; +use Bottledcode\DurablePhp\Events\RevokeUser; +use Bottledcode\DurablePhp\Events\ShareOwnership; +use Bottledcode\DurablePhp\Events\Shares\Operation; +use Bottledcode\DurablePhp\Events\ShareWithRole; +use Bottledcode\DurablePhp\Events\ShareWithUser; use Bottledcode\DurablePhp\Events\StartExecution; use Bottledcode\DurablePhp\Events\TaskCompleted; use Bottledcode\DurablePhp\Events\WithDelay; @@ -188,4 +195,64 @@ public function currentUserId(): string { return $this->user->userId; } + + public function shareOwnership(string $withUser): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareOwnership::withUser($withUser), + ), + ); + } + + public function giveOwnership(string $withUser): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + GiveOwnership::withUser($withUser), + ), + ); + } + + public function grantUser(string $withUser, Operation ...$operation): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareWithUser::For($withUser, ...$operation), + ), + ); + } + + public function grantRole(string $withRole, Operation ...$operation): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + ShareWithRole::For($withRole, ...$operation), + ), + ); + } + + public function revokeUser(string $user): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + RevokeUser::completely($user), + ), + ); + } + + public function revokeRole(string $role): void + { + $this->eventDispatcher->fire( + WithEntity::forInstance( + StateId::fromEntityId($this->id), + RevokeRole::completely($role), + ), + ); + } } diff --git a/src/EntityContextInterface.php b/src/EntityContextInterface.php index 873c9682..adc2683a 100644 --- a/src/EntityContextInterface.php +++ b/src/EntityContextInterface.php @@ -24,6 +24,7 @@ namespace Bottledcode\DurablePhp; +use Bottledcode\DurablePhp\Events\Shares\Operation; use Bottledcode\DurablePhp\State\EntityId; use Closure; use Crell\Serde\Attributes\ClassNameTypeMap; @@ -112,4 +113,16 @@ public function delayUntil( public function delay(Closure $self, DateTimeInterface $until = new DateTimeImmutable()): void; public function currentUserId(): string; + + public function shareOwnership(string $withUser): void; + + public function grantUser(string $withUser, Operation ...$operation): void; + + public function grantRole(string $withRole, Operation ...$operation): void; + + public function revokeUser(string $user): void; + + public function revokeRole(string $role): void; + + public function giveOwnership(string $withUser): void; } diff --git a/src/Events/RevokeRole.php b/src/Events/RevokeRole.php index bba60c81..04757628 100644 --- a/src/Events/RevokeRole.php +++ b/src/Events/RevokeRole.php @@ -1,4 +1,5 @@ role, ); + return sprintf('Revoke(role: %s)', $this->role); } } diff --git a/src/Events/RevokeUser.php b/src/Events/RevokeUser.php index df357a51..b3032f97 100644 --- a/src/Events/RevokeUser.php +++ b/src/Events/RevokeUser.php @@ -1,4 +1,5 @@ Date: Tue, 29 Jul 2025 22:41:03 +0200 Subject: [PATCH 40/51] refactor ids Signed-off-by: Robert Landers --- cli/auth/resource.go | 9 ++++--- cli/auth/resourceManager.go | 3 ++- cli/cli.go | 45 ++++++++++++++++--------------- cli/glue/glue.go | 32 +++++++--------------- cli/glue/response_writer.go | 7 ++--- cli/glue/state.go | 3 ++- cli/{glue/sanity.go => ids/id.go} | 12 ++++----- cli/lib/api.go | 44 +++++++++++++++++++----------- cli/lib/billing.go | 7 ++--- cli/lib/consumer.go | 21 +++++++++++---- cli/lib/indexer.go | 15 ++++++----- cli/lib/locks.go | 8 +++--- src/Contexts/AuthContext.php | 6 +++-- 13 files changed, 116 insertions(+), 96 deletions(-) rename cli/{glue/sanity.go => ids/id.go} (99%) diff --git a/cli/auth/resource.go b/cli/auth/resource.go index 681a7672..899c80d9 100644 --- a/cli/auth/resource.go +++ b/cli/auth/resource.go @@ -4,6 +4,7 @@ import ( "context" "durable_php/appcontext" "durable_php/glue" + "durable_php/ids" "encoding/json" "errors" "fmt" @@ -50,7 +51,7 @@ type Resource struct { Mode Mode `json:"mode"` mu sync.RWMutex kv jetstream.KeyValue - id *glue.StateId + id *ids.StateId Expires time.Time revision uint64 } @@ -107,7 +108,7 @@ func (r *Resource) ShareOwnership(newUser UserId, currentUser *User, keepPermiss return nil } -func (r *Resource) ApplyPerms(id *glue.StateId, ctx context.Context, logger *zap.Logger) bool { +func (r *Resource) ApplyPerms(id *ids.StateId, ctx context.Context, logger *zap.Logger) bool { perms, err := r.getOrCreatePermissions(id, ctx, logger) if err != nil { logger.Error("failed to get permissions", zap.Error(err)) @@ -122,7 +123,7 @@ func (r *Resource) ApplyPerms(id *glue.StateId, ctx context.Context, logger *zap } // CanCreate Load permissions from cache if available, otherwise fetch from external source -func (r *Resource) CanCreate(id *glue.StateId, ctx context.Context, logger *zap.Logger) bool { +func (r *Resource) CanCreate(id *ids.StateId, ctx context.Context, logger *zap.Logger) bool { perms, err := r.getOrCreatePermissions(id, ctx, logger) if err != nil { logger.Error("failed to create permissions", zap.Error(err)) @@ -131,7 +132,7 @@ func (r *Resource) CanCreate(id *glue.StateId, ctx context.Context, logger *zap. return r.isUserPermitted(perms, ctx) } -func (r *Resource) getOrCreatePermissions(id *glue.StateId, ctx context.Context, logger *zap.Logger) (CreatePermissions, error) { +func (r *Resource) getOrCreatePermissions(id *ids.StateId, ctx context.Context, logger *zap.Logger) (CreatePermissions, error) { var perms CreatePermissions if cached, found := cache.Load(id.Name()); found { perms = cached.(CreatePermissions) diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go index ed06ce07..0ec92724 100644 --- a/cli/auth/resourceManager.go +++ b/cli/auth/resourceManager.go @@ -4,6 +4,7 @@ import ( "context" "durable_php/appcontext" "durable_php/glue" + "durable_php/ids" "encoding/json" "github.com/modern-go/concurrent" "github.com/nats-io/nats.go" @@ -46,7 +47,7 @@ func GetResourceManager(ctx context.Context, stream jetstream.JetStream) *Resour // DiscoverResource is a method of the ResourceManager struct that is responsible for discovering a resource based on // the provided context, state ID, logger, and preventCreation flag -func (r *ResourceManager) DiscoverResource(ctx context.Context, id *glue.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) { +func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) { currentUser, _ := ctx.Value(appcontext.CurrentUserKey).(*User) data, err := r.kv.Get(ctx, id.ToSubject().String()) diff --git a/cli/cli.go b/cli/cli.go index 6807ad73..bbef22d8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -27,6 +27,7 @@ import ( "durable_php/auth" "durable_php/config" "durable_php/glue" + "durable_php/ids" di "durable_php/init" "durable_php/lib" "encoding/json" @@ -180,9 +181,9 @@ func execute(args []string, options map[string]string) int { }) consumers := []string{ - string(glue.Activity), - string(glue.Entity), - string(glue.Orchestration), + string(ids.Activity), + string(ids.Entity), + string(ids.Orchestration), } for _, kind := range consumers { @@ -211,30 +212,30 @@ func execute(args []string, options map[string]string) int { if options["no-activities"] != "true" { logger.Info("Starting activity consumer") - go lib.BuildConsumer(stream, ctx, cfg, glue.Activity, logger, js, rm) + go lib.BuildConsumer(stream, ctx, cfg, ids.Activity, logger, js, rm) } if options["no-entities"] != "true" { logger.Info("Starting entity consumer") - go lib.BuildConsumer(stream, ctx, cfg, glue.Entity, logger, js, rm) + go lib.BuildConsumer(stream, ctx, cfg, ids.Entity, logger, js, rm) } if options["no-orchestrations"] != "true" { logger.Info("Starting orchestration consumer") - go lib.BuildConsumer(stream, ctx, cfg, glue.Orchestration, logger, js, rm) + go lib.BuildConsumer(stream, ctx, cfg, ids.Orchestration, logger, js, rm) } if len(cfg.Extensions.Search.Collections) > 0 { for _, collection := range cfg.Extensions.Search.Collections { switch collection { case "entities": - err := lib.IndexerListen(ctx, cfg, glue.Entity, js, logger) + err := lib.IndexerListen(ctx, cfg, ids.Entity, js, logger) if err != nil { cfg.Extensions.Search.Collections = []string{} logger.Warn("Disabling search extension due to failing to connect to typesense") } case "orchestrations": - err := lib.IndexerListen(ctx, cfg, glue.Orchestration, js, logger) + err := lib.IndexerListen(ctx, cfg, ids.Orchestration, js, logger) if err != nil { cfg.Extensions.Search.Collections = []string{} logger.Warn("Disabling search extension due to failing to connect to typesense") @@ -451,13 +452,13 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - var store glue.IdKind + var store ids.IdKind switch args[0] { - case string(glue.Orchestration): - store = glue.Orchestration + case string(ids.Orchestration): + store = ids.Orchestration if len(args) == 1 { - kv, err := js.KeyValue(ctx, string(glue.Orchestration)) + kv, err := js.KeyValue(ctx, string(ids.Orchestration)) if err != nil { fmt.Println("[]") return 0 @@ -478,10 +479,10 @@ func main() { fmt.Println(string(marshal)) return 0 } - case string(glue.Activity): - store = glue.Activity - case string(glue.Entity): - store = glue.Entity + case string(ids.Activity): + store = ids.Activity + case string(ids.Entity): + store = ids.Entity default: panic(fmt.Errorf("invalid type: %s", args[0])) } @@ -502,14 +503,14 @@ func main() { return 0 } - var id *glue.StateId + var id *ids.StateId switch store { - case glue.Entity: + case ids.Entity: fallthrough - case glue.Orchestration: - id = glue.ParseStateId(fmt.Sprintf("%s:%s:%s", string(store), args[1], args[2])) - case glue.Activity: - id = glue.ParseStateId(fmt.Sprintf("%s:%s", string(glue.Activity), args[0])) + case ids.Orchestration: + id = ids.ParseStateId(fmt.Sprintf("%s:%s:%s", string(store), args[1], args[2])) + case ids.Activity: + id = ids.ParseStateId(fmt.Sprintf("%s:%s", string(ids.Activity), args[0])) } ctx, cancel = context.WithCancel(ctx) diff --git a/cli/glue/glue.go b/cli/glue/glue.go index 4d7e2ca3..42bfefc1 100644 --- a/cli/glue/glue.go +++ b/cli/glue/glue.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "durable_php/appcontext" - "durable_php/auth" + "durable_php/ids" "encoding/json" "fmt" "github.com/dunglas/frankenphp" @@ -12,7 +12,6 @@ import ( "github.com/nats-io/nats.go/jetstream" "go.uber.org/zap" "io" - "maps" "net/http" "net/url" "os" @@ -74,7 +73,7 @@ func NewGlue(bootstrap string, function Method, input []any, payload string) *Gl } } -func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) { +func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *ids.StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) { temp, err := os.CreateTemp("", "reqbody") if err != nil { return nil, "", err, nil, false @@ -113,7 +112,7 @@ func FromApiRequest(ctx context.Context, r *http.Request, function Method, logge return msgs, temp.Name(), nil, &responseHeaders, deleteAfter } -func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *StateId) ([]*nats.Msg, http.Header, int, bool) { +func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *ids.StateId) ([]*nats.Msg, http.Header, int, bool) { var dir string var ok bool if dir, ok = GetLibraryDir("glue.php"); !ok { @@ -128,17 +127,6 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log headers.Add("DPHP_FUNCTION", string(g.function)) headers.Add("DPHP_PAYLOAD", g.payload) - rm := auth.GetResourceManager(ctx, stream) - res, err := rm.DiscoverResource(ctx, id, logger, true) - if err != nil { - logger.Error("DiscoverResource", zap.Error(err)) - panic(err) - } - if res != nil { - ac, _ := rm.ToAuthContext(ctx, res) - headers.Add("DPHP_AUTH_CONTEXT", string(ac)) - } - provenance := ctx.Value(appcontext.CurrentUserKey) if provenance != nil { provenanceJson, err := json.Marshal(provenance) @@ -205,7 +193,7 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log go func() { mu := sync.Mutex{} for query := range writer.query { - id := ParseStateId(query[0]) + id := ids.ParseStateId(query[0]) qid := query[1] wg.Add(1) go func() { @@ -232,11 +220,11 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log return writer.events, writer.Header(), writer.status, writer.DeleteAfter } -func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Logger, id *StateId) error { +func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Logger, id *ids.StateId) error { logger.Info("Deleting state", zap.Any("id", id)) - if id.Kind == Orchestration { + if id.Kind == ids.Orchestration { bucket, err := stream.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: string(Orchestration), + Bucket: string(ids.Orchestration), Compression: true, }) if err != nil { @@ -264,11 +252,11 @@ func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Lo return nil } -func GetStateFile(id *StateId, stream jetstream.JetStream, ctx context.Context, logger *zap.Logger) (*os.File, func() error) { - if id.Kind == Orchestration { +func GetStateFile(id *ids.StateId, stream jetstream.JetStream, ctx context.Context, logger *zap.Logger) (*os.File, func() error) { + if id.Kind == ids.Orchestration { // orchestrations use optimistic concurrency and the kv store for state bucket, err := stream.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: string(Orchestration), + Bucket: string(ids.Orchestration), Description: "Holds orchestration state and history", Compression: true, }) diff --git a/cli/glue/response_writer.go b/cli/glue/response_writer.go index 0ea2c401..4d57346e 100644 --- a/cli/glue/response_writer.go +++ b/cli/glue/response_writer.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "durable_php/appcontext" + "durable_php/ids" "encoding/json" "github.com/nats-io/nats.go" "go.uber.org/zap" @@ -53,7 +54,7 @@ type InternalLoggingResponseWriter struct { events []*nats.Msg query chan []string headers http.Header - CurrentId *StateId + CurrentId *ids.StateId Context context.Context DeleteAfter bool } @@ -78,10 +79,10 @@ func (w *InternalLoggingResponseWriter) Write(b []byte) (int, error) { return len(b), err } - destinationId := ParseStateId(body.Destination) + destinationId := ids.ParseStateId(body.Destination) replyTo := "" if body.ReplyTo != "" { - replyTo = ParseStateId(body.ReplyTo).ToSubject().String() + replyTo = ids.ParseStateId(body.ReplyTo).ToSubject().String() } now, _ := time.Now().MarshalText() diff --git a/cli/glue/state.go b/cli/glue/state.go index 46e8ce4e..50693ffc 100644 --- a/cli/glue/state.go +++ b/cli/glue/state.go @@ -2,10 +2,11 @@ package glue import ( "context" + "durable_php/ids" "github.com/nats-io/nats.go/jetstream" ) -func GetObjectStore(kind IdKind, js jetstream.JetStream, ctx context.Context) (jetstream.ObjectStore, error) { +func GetObjectStore(kind ids.IdKind, js jetstream.JetStream, ctx context.Context) (jetstream.ObjectStore, error) { obj, err := js.CreateOrUpdateObjectStore(ctx, jetstream.ObjectStoreConfig{ Bucket: string(kind), diff --git a/cli/glue/sanity.go b/cli/ids/id.go similarity index 99% rename from cli/glue/sanity.go rename to cli/ids/id.go index 43f4b04d..4247c7dd 100644 --- a/cli/glue/sanity.go +++ b/cli/ids/id.go @@ -1,4 +1,4 @@ -package glue +package ids import ( "fmt" @@ -42,11 +42,6 @@ func (subj *Subject) Bucket() string { // state ids -type StateId struct { - Id string - Kind IdKind -} - func fromEntityId(entity *EntityId) *StateId { return &StateId{ Id: entity.String(), @@ -177,3 +172,8 @@ func (id *OrchestrationId) String() string { func (id *OrchestrationId) ToStateId() *StateId { return fromOrchestrationId(id) } + +type StateId struct { + Id string + Kind IdKind +} diff --git a/cli/lib/api.go b/cli/lib/api.go index af3f4621..a7a3de35 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -5,6 +5,7 @@ import ( "durable_php/auth" "durable_php/config" "durable_php/glue" + "durable_php/ids" "encoding/json" "fmt" "github.com/dunglas/frankenphp" @@ -164,7 +165,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logRequest(logger, request, ctx) vars := mux.Vars(request) - id := &glue.ActivityId{ + id := &ids.ActivityId{ Id: vars["id"], } err := OutputStatus(ctx, writer, id.ToStateId(), js, logger) @@ -255,11 +256,22 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po bootstrap := ctx.Value("bootstrap").(string) - processReq := func(ctx context.Context, writer http.ResponseWriter, request *http.Request, id *glue.StateId, function glue.Method, headers http.Header) { + processReq := func(ctx context.Context, writer http.ResponseWriter, request *http.Request, id *ids.StateId, function glue.Method, headers http.Header) { logger.Debug("Processing request to call function", zap.String("function", string(function)), zap.Any("Headers", headers)) ctx, cancel := context.WithCancel(context.WithValue(ctx, "bootstrap", bootstrap)) defer cancel() + rm := auth.GetResourceManager(ctx, js) + res, err := rm.DiscoverResource(ctx, id, logger, true) + if err != nil { + logger.Error("DiscoverResource", zap.Error(err)) + panic(err) + } + if res != nil { + ac, _ := rm.ToAuthContext(ctx, res) + headers.Add("DPHP_AUTH_CONTEXT", string(ac)) + } + msgs, stateFile, err, responseHeaders, deleteAfter := glue.FromApiRequest(ctx, request, function, logger, js, id, headers) if err != nil { http.Error(writer, "Internal Server Error", http.StatusInternalServerError) @@ -330,7 +342,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logRequest(logger, request, ctx) vars := mux.Vars(request) - id := &glue.EntityId{ + id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), Id: strings.TrimSpace(vars["id"]), } @@ -381,7 +393,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - id := &glue.EntityId{ + id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), Id: strings.TrimSpace(vars["id"]), } @@ -456,7 +468,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - id := &glue.EntityId{ + id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), Id: strings.TrimSpace(vars["id"]), } @@ -508,7 +520,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - id := &glue.EntityId{ + id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), Id: strings.TrimSpace(vars["id"]), } @@ -624,7 +636,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: vars["name"], ExecutionId: execId.String(), } @@ -652,7 +664,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logRequest(logger, request, ctx) vars := mux.Vars(request) - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), ExecutionId: strings.TrimSpace(vars["id"]), } @@ -703,7 +715,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), ExecutionId: strings.TrimSpace(vars["id"]), } @@ -778,7 +790,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po } vars := mux.Vars(request) - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), ExecutionId: strings.TrimSpace(vars["id"]), } @@ -833,7 +845,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po ctx := getCorrelationId(ctx, &request.Header, nil) logRequest(logger, request, ctx) - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), ExecutionId: strings.TrimSpace(vars["id"]), } @@ -899,7 +911,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po defer cancel() bucket, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: string(glue.Orchestration), + Bucket: string(ids.Orchestration), Description: "Holds orchestration state and history", Compression: true, }) @@ -964,7 +976,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po logRequest(logger, request, ctx) vars := mux.Vars(request) - id := &glue.OrchestrationId{ + id := &ids.OrchestrationId{ InstanceId: vars["name"], ExecutionId: vars["id"], } @@ -996,7 +1008,7 @@ func authorize( config *config.Config, ctx context.Context, rm *auth.ResourceManager, - id *glue.StateId, + id *ids.StateId, logger *zap.Logger, preventCreation bool, operation auth.Operation, @@ -1066,7 +1078,7 @@ func OutputList(writer http.ResponseWriter, store jetstream.ObjectStore) { continue } - id := glue.ParseStateId(activity.Headers.Get(string(glue.HeaderStateId))) + id := ids.ParseStateId(activity.Headers.Get(string(glue.HeaderStateId))) t := id.String() parts := strings.Split(t, ":")[1:] names = append(names, parts) @@ -1078,7 +1090,7 @@ func OutputList(writer http.ResponseWriter, store jetstream.ObjectStore) { } } -func OutputStatus(ctx context.Context, writer http.ResponseWriter, id *glue.StateId, stream jetstream.JetStream, logger *zap.Logger) error { +func OutputStatus(ctx context.Context, writer http.ResponseWriter, id *ids.StateId, stream jetstream.JetStream, logger *zap.Logger) error { ctx, cancel := context.WithCancel(ctx) defer cancel() stateFile, _ := glue.GetStateFile(id, stream, ctx, logger) diff --git a/cli/lib/billing.go b/cli/lib/billing.go index 4734bb57..c19a3422 100644 --- a/cli/lib/billing.go +++ b/cli/lib/billing.go @@ -4,6 +4,7 @@ import ( "context" "durable_php/config" "durable_php/glue" + "durable_php/ids" "encoding/json" "fmt" "github.com/nats-io/nats.go/jetstream" @@ -42,7 +43,7 @@ func StartBillingProcessor(ctx context.Context, config *config.Config, js jetstr return err } - maybeSendActivityBilling := func(id *glue.StateId) { + maybeSendActivityBilling := func(id *ids.StateId) { started, err := activityTracker.Get(ctx, id.ToSubject().String()+"_start") if err != nil { return @@ -87,9 +88,9 @@ func StartBillingProcessor(ctx context.Context, config *config.Config, js jetstr consume, err := consumer.Consume(func(msg jetstream.Msg) { targetType := msg.Headers().Get(string(glue.HeaderTargetType)) eventType := msg.Headers().Get(string(glue.HeaderEventType)) - id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) + id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) nowBytes := []byte(msg.Headers().Get(string(glue.HeaderEmittedAt))) - emittedBy := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) + emittedBy := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) switch targetType { case "Activity": diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go index 142e16ce..87311a67 100644 --- a/cli/lib/consumer.go +++ b/cli/lib/consumer.go @@ -6,6 +6,7 @@ import ( "durable_php/auth" "durable_php/config" "durable_php/glue" + "durable_php/ids" "encoding/json" "fmt" "github.com/nats-io/nats.go/jetstream" @@ -16,7 +17,7 @@ import ( "time" ) -func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.Config, kind glue.IdKind, logger *zap.Logger, js jetstream.JetStream, rm *auth.ResourceManager) { +func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.Config, kind ids.IdKind, logger *zap.Logger, js jetstream.JetStream, rm *auth.ResourceManager) { logger.Debug("Creating consumer", zap.String("stream", config.Stream), zap.String("kind", string(kind))) consumer, err := stream.Consumer(ctx, config.Stream+"-"+string(kind)) @@ -55,7 +56,7 @@ func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config. } if strings.HasSuffix(msg.Subject(), ".delete") { - id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) + id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) err := glue.DeleteState(ctx, js, logger, id) if err != nil { panic(err) @@ -98,8 +99,8 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j logger.Debug("Received message", zap.Any("msg", msg)) // lock the Subject, if it is a lockable Subject - id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) - if id.Kind == glue.Entity { + id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId))) + if id.Kind == ids.Entity { unlocker, err := lockSubject(ctx, id.ToSubject(), js, logger) if err != nil { return err @@ -128,7 +129,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j // extract the source operations sourceOps := strings.Split(msg.Headers().Get(string(glue.HeaderSourceOps)), ",") // retrieve the source - sourceId := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) + sourceId := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy))) if sourceR, err := rm.DiscoverResource(ctx, sourceId, logger, true); err != nil { if sourceR == nil { logger.Warn("User accessed missing object", zap.Any("operation", sourceOps), zap.String("from", sourceId.Id), zap.String("to", id.Id), zap.String("user", string(currentUser.UserId))) @@ -291,6 +292,16 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j env["STATE_ID"] = msg.Headers().Get(string(glue.HeaderStateId)) env["REMOTE_ADDR"] = msg.Headers().Get("Remote-Addr") + res, err := rm.DiscoverResource(ctx, id, logger, true) + if err != nil { + logger.Error("DiscoverResource", zap.Error(err)) + panic(err) + } + if res != nil { + ac, _ := rm.ToAuthContext(ctx, res) + headers.Add("DPHP_AUTH_CONTEXT", string(ac)) + } + msgs, headers, _, deleteAfter := glu.Execute(ctx, headers, logger, env, js, id) // now update the stored state, if this fails due to optimistic concurrency, we immediately nak and fail diff --git a/cli/lib/indexer.go b/cli/lib/indexer.go index 3087083c..7cfe0b3f 100644 --- a/cli/lib/indexer.go +++ b/cli/lib/indexer.go @@ -4,6 +4,7 @@ import ( "context" "durable_php/config" "durable_php/glue" + "durable_php/ids" "encoding/json" "github.com/nats-io/nats.go/jetstream" "github.com/typesense/typesense-go/typesense" @@ -15,13 +16,13 @@ import ( "time" ) -func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, js jetstream.JetStream, logger *zap.Logger) error { +func IndexerListen(ctx context.Context, config *config.Config, kind ids.IdKind, js jetstream.JetStream, logger *zap.Logger) error { //logger.Info("Starting indexer extension", zap.String("for", string(kind)), zap.Any("config", config.Extensions.Search)) client := typesense.NewClient(typesense.WithServer(config.Extensions.Search.Url), typesense.WithAPIKey(config.Extensions.Search.Key)) switch kind { - case glue.Entity: + case ids.Entity: collection := client.Collection(config.Stream + "_entities") err := CreateEntityIndex(ctx, client, config) @@ -99,7 +100,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, go func() { ctx, done := context.WithCancel(ctx) - obj, err := glue.GetObjectStore(glue.Entity, js, ctx) + obj, err := glue.GetObjectStore(ids.Entity, js, ctx) if err != nil { logger.Warn("Unable to load state for entity", zap.Error(err)) done() @@ -119,7 +120,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, done() return } - id := glue.ParseStateId(result["id"].(map[string]interface{})["id"].(string)) + id := ids.ParseStateId(result["id"].(map[string]interface{})["id"].(string)) eid, _ := id.ToEntityId() entityData := struct { @@ -140,7 +141,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, }() } }() - case glue.Orchestration: + case ids.Orchestration: collection := client.Collection(config.Stream + "_orchestrations") err := CreateOrchestrationIndex(ctx, client, config) @@ -148,7 +149,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, return err } - obj, err := js.KeyValue(ctx, string(glue.Orchestration)) + obj, err := js.KeyValue(ctx, string(ids.Orchestration)) if err != nil { // key value doesn't exist yet, try again in a few minutes go func() { @@ -226,7 +227,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, return } - id := glue.ParseStateId(result["id"].(map[string]interface{})["id"].(string)) + id := ids.ParseStateId(result["id"].(map[string]interface{})["id"].(string)) oid, _ := id.ToOrchestrationId() status := result["status"].(map[string]interface{}) diff --git a/cli/lib/locks.go b/cli/lib/locks.go index c251588c..2b72b41f 100644 --- a/cli/lib/locks.go +++ b/cli/lib/locks.go @@ -2,7 +2,7 @@ package lib import ( "context" - "durable_php/glue" + "durable_php/ids" "errors" "github.com/nats-io/nats.go/jetstream" "go.uber.org/zap" @@ -14,7 +14,7 @@ const ( LockKey string = "lock" ) -func acquireLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyValue, logger *zap.Logger) (bool, uint64) { +func acquireLock(ctx context.Context, subject *ids.Subject, kv jetstream.KeyValue, logger *zap.Logger) (bool, uint64) { value, err := kv.Get(ctx, LockKey) // not found or empty value if err != nil || string(value.Value()) == "" { @@ -31,7 +31,7 @@ func acquireLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyVal return false, value.Revision() } -func waitForLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyValue, logger *zap.Logger) bool { +func waitForLock(ctx context.Context, subject *ids.Subject, kv jetstream.KeyValue, logger *zap.Logger) bool { logger.Debug("Waiting for lock", zap.String("Subject", subject.String())) ok, revision := acquireLock(ctx, subject, kv, logger) @@ -61,7 +61,7 @@ func waitForLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyVal } } -func lockSubject(ctx context.Context, subject *glue.Subject, js jetstream.JetStream, logger *zap.Logger) (func() error, error) { +func lockSubject(ctx context.Context, subject *ids.Subject, js jetstream.JetStream, logger *zap.Logger) (func() error, error) { logger.Debug("Attempting to take lock", zap.String("Subject", subject.String())) kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: subject.Bucket(), diff --git a/src/Contexts/AuthContext.php b/src/Contexts/AuthContext.php index a3317ff5..de8c9478 100644 --- a/src/Contexts/AuthContext.php +++ b/src/Contexts/AuthContext.php @@ -9,7 +9,7 @@ use Crell\Serde\Attributes\SequenceField; use Withinboredom\Record; -abstract readonly class AuthContext extends Record +readonly class AuthContext extends Record { public StateId $contextId; @@ -28,7 +28,9 @@ public static function fromCurrentContext(): ?AuthContext { if (isset($_SERVER['HTTP_DPHP_AUTH_CONTEXT'])) { - return Serializer::deserialize($_SERVER['HTTP_DPHP_AUTH_CONTEXT'], self::class); + $json = json_decode($_SERVER['HTTP_DPHP_AUTH_CONTEXT'], true, flags: JSON_THROW_ON_ERROR); + + return Serializer::deserialize($json, self::class); } return null; From b6c8c5c4612849d8756f76480bd138e002d7051d Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 10:50:24 +0200 Subject: [PATCH 41/51] fix minor auth bugs Signed-off-by: Robert Landers --- cli/auth/keys.go | 2 +- cli/auth/resourceManager.go | 4 ++-- cli/lib/api.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/auth/keys.go b/cli/auth/keys.go index b114ddf6..3dc4e5e9 100644 --- a/cli/auth/keys.go +++ b/cli/auth/keys.go @@ -118,7 +118,7 @@ func CreateUser(userId UserId, role []Role, claims map[string]string, config *co claimMap := jwt.MapClaims{ "sub": userId, "exp": time.Now().Add(72 * time.Hour).Unix(), - "iat": time.Now(), + "iat": time.Now().Add(-5 * time.Minute).Unix(), "nbf": time.Now().Add(-5 * time.Minute).Unix(), "roles": role, } diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go index 0ec92724..facc4f5c 100644 --- a/cli/auth/resourceManager.go +++ b/cli/auth/resourceManager.go @@ -91,7 +91,7 @@ func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, } func (r *ResourceManager) ToAuthContext(ctx context.Context, resource *Resource) ([]byte, error) { - var owners []map[string]interface{} + owners := []map[string]interface{}{} for o, _ := range resource.Owners { owners = append(owners, map[string]interface{}{ @@ -101,7 +101,7 @@ func (r *ResourceManager) ToAuthContext(ctx context.Context, resource *Resource) }) } - var shares []map[string]interface{} + shares := []map[string]interface{}{} for _, s := range resource.Shares { if u, ok := s.(*UserShare); ok { diff --git a/cli/lib/api.go b/cli/lib/api.go index a7a3de35..c04eef22 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -1022,7 +1022,7 @@ func authorize( } resource, err := rm.DiscoverResource(ctx, id, logger, preventCreation) if err != nil { - logger.Warn("User attempted to create new resource not authorized to create", zap.Any("id", id.String()), zap.Error(err)) + logger.Warn("User attempted to create new resource not authorized to create", zap.Any("id", id.String()), zap.Any("user", auth.GetUserFromContext(ctx)), zap.Error(err)) http.Error(writer, "Not Authorized", http.StatusForbidden) return nil, true } From 87ae83515399fe5ef9410294a34b0ebbb6b414a8 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 11:14:13 +0200 Subject: [PATCH 42/51] handle array claims Signed-off-by: Robert Landers --- cli/auth/keys.go | 3 +-- cli/cli.go | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cli/auth/keys.go b/cli/auth/keys.go index 3dc4e5e9..83445308 100644 --- a/cli/auth/keys.go +++ b/cli/auth/keys.go @@ -114,7 +114,7 @@ func ExtractUser(r *http.Request, config *config.Config) (user *User, ok bool) { // The token is signed using the active secret key from the config. // The token will expire in 72 hours and is valid starting from 5 minutes ago. // Returns the signed token string or an error if the signing process fails. -func CreateUser(userId UserId, role []Role, claims map[string]string, config *config.Config) (string, error) { +func CreateUser(userId UserId, role []Role, claims map[string]interface{}, config *config.Config) (string, error) { claimMap := jwt.MapClaims{ "sub": userId, "exp": time.Now().Add(72 * time.Hour).Unix(), @@ -125,7 +125,6 @@ func CreateUser(userId UserId, role []Role, claims map[string]string, config *co for k, v := range claims { k = strings.TrimSpace(k) - v = strings.TrimSpace(v) claimMap[k] = v } diff --git a/cli/cli.go b/cli/cli.go index bbef22d8..53017760 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -548,7 +548,7 @@ func main() { rol = append(rol, auth.Role(role)) } - extraClaims := make(map[string]string) + extraClaims := make(map[string]interface{}) if options["claims"] != "" { claims := strings.Split(options["claims"], ";") for _, claim := range claims { @@ -556,7 +556,11 @@ func main() { if len(kv) != 2 { panic(fmt.Errorf("invalid claim: %s", claim)) } - extraClaims[kv[0]] = kv[1] + if strings.Contains(kv[1], ",") { + extraClaims[kv[0]] = strings.Split(strings.TrimSpace(kv[1]), ",") + } else { + extraClaims[kv[0]] = strings.TrimSpace(kv[1]) + } } } From 71cb38b6c5c126a043dbd6582500e8569289b59f Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 11:59:06 +0200 Subject: [PATCH 43/51] remove unused var Signed-off-by: Robert Landers --- src/Proxy/SpyProxy.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Proxy/SpyProxy.php b/src/Proxy/SpyProxy.php index 58b68475..a3534f69 100644 --- a/src/Proxy/SpyProxy.php +++ b/src/Proxy/SpyProxy.php @@ -37,12 +37,10 @@ protected function pureMethod(ReflectionMethod $method, bool $isHook = false): s protected function impureCall(ReflectionMethod $method, bool $isHook = false): string { - $getHook = true; if ($isHook && str_ends_with($method->getName(), 'get')) { $name = 'get'; } elseif ($isHook && str_ends_with($method->getName(), 'set')) { $name = 'set'; - $getHook = false; } else { $name = $method->getName(); } From a68f1672ee30baf8eeed0b30c244f85de5aaee82 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 12:01:18 +0200 Subject: [PATCH 44/51] handle logging of request Signed-off-by: Robert Landers --- cli/lib/api.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli/lib/api.go b/cli/lib/api.go index c04eef22..440fa5b5 100644 --- a/cli/lib/api.go +++ b/cli/lib/api.go @@ -392,6 +392,9 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + vars := mux.Vars(request) id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), @@ -467,6 +470,9 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + vars := mux.Vars(request) id := &ids.EntityId{ Name: strings.TrimSpace(vars["name"]), @@ -714,6 +720,9 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + vars := mux.Vars(request) id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), @@ -789,6 +798,9 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po return } + ctx := getCorrelationId(ctx, &request.Header, nil) + logRequest(logger, request, ctx) + vars := mux.Vars(request) id := &ids.OrchestrationId{ InstanceId: strings.TrimSpace(vars["name"]), From 7d6977ab35c072e1a352db123b725e942bde19b4 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 12:09:20 +0200 Subject: [PATCH 45/51] ignore the watcher deps Signed-off-by: Robert Landers --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 70ea8cfd..b09538d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -107,7 +107,7 @@ ENV GOBIN=/usr/local/bin RUN go get durable_php #RUN go test ./... -RUN go install -ldflags "-w -s -X 'main.version=$VERSION'" +RUN go install --tags nowatcher -ldflags "-w -s -X 'main.version=$VERSION'" FROM common AS durable-php COPY --from=builder /usr/local/bin/durable_php /usr/local/bin/dphp From 000cd188a5472ce4658d6f07962c150cade28ffd Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 12:23:03 +0200 Subject: [PATCH 46/51] fix docker build Signed-off-by: Robert Landers --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b09538d2..fb320e8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -107,7 +107,7 @@ ENV GOBIN=/usr/local/bin RUN go get durable_php #RUN go test ./... -RUN go install --tags nowatcher -ldflags "-w -s -X 'main.version=$VERSION'" +RUN CGO_CFLAGS=$(php-config --includes) CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" go install --tags nowatcher -ldflags "-w -s -X 'main.version=$VERSION'" FROM common AS durable-php COPY --from=builder /usr/local/bin/durable_php /usr/local/bin/dphp From 76ce2112ba90de9270e2aeafd4424ac7804538de Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 13:19:03 +0200 Subject: [PATCH 47/51] remove watcher Signed-off-by: Robert Landers --- cli/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/build.sh b/cli/build.sh index d745c407..1b70c12e 100755 --- a/cli/build.sh +++ b/cli/build.sh @@ -118,7 +118,7 @@ fi env go env go get durable_php -go build -buildmode=pie -tags "cgo netgo nats osusergo static_build" -ldflags "-linkmode=external -extldflags '-static-pie ${extraExtldflags}' ${extraLdflags} -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP ${FRANKENPHP_VERSION} PHP ${LIBPHP_VERSION} go_durable_php' -X 'main.version=$VERSION'" -o "dist/${bin}" durable_php +go build -buildmode=pie -tags "cgo netgo nats osusergo static_build nowatcher" -ldflags "-linkmode=external -extldflags '-static-pie ${extraExtldflags}' ${extraLdflags} -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP ${FRANKENPHP_VERSION} PHP ${LIBPHP_VERSION} go_durable_php' -X 'main.version=$VERSION'" -o "dist/${bin}" durable_php if [ -d "${EMBED}" ]; then truncate -s 0 app.tar From 37e6b8f61362e10c1429a5a118a914cf45949713 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 18:08:24 +0200 Subject: [PATCH 48/51] delete workflows that do not matter Signed-off-by: Robert Landers --- .github/workflows/build-cli.yaml | 76 ----------------- Dockerfile | 31 +++---- cli/build-php.sh | 124 ---------------------------- cli/build.sh | 135 ------------------------------- 4 files changed, 16 insertions(+), 350 deletions(-) delete mode 100755 cli/build-php.sh delete mode 100755 cli/build.sh diff --git a/.github/workflows/build-cli.yaml b/.github/workflows/build-cli.yaml index 0cf69504..ff3551ee 100644 --- a/.github/workflows/build-cli.yaml +++ b/.github/workflows/build-cli.yaml @@ -13,35 +13,6 @@ on: branches: - v2 jobs: - build-linux: - strategy: - fail-fast: false - matrix: - platform: [ 'amd64', 'arm64' ] - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - name: Configure QEMU - uses: docker/setup-qemu-action@v3 - - name: Configure docker - uses: docker/setup-buildx-action@v3 - with: - platforms: ${{ matrix.platform }} - - name: Expose GitHub Runtime - uses: crazy-max/ghaction-github-runtime@v3 - - name: Build sources - run: | - echo "${{ secrets.GITHUB_TOKEN }}" > TOKEN - docker buildx build --secret id=github-token,src=./TOKEN --cache-to type=gha,mode=max,scope=${{ matrix.platform }} --cache-from type=gha,scope=${{ matrix.platform }} --pull --load --platform linux/${{ matrix.platform }} --target cli-base-alpine -t builder . - - name: Copy build - run: | - docker create --name builder builder - docker cp builder:/go/src/app/cli/dist/dphp bin/dphp - - name: Archive artifacts - uses: actions/upload-artifact@v4 - with: - name: dphp-${{ runner.os }}-${{ matrix.platform }} - path: bin/dphp build-docker: runs-on: self-hosted outputs: @@ -108,53 +79,6 @@ jobs: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=image platforms: linux/amd64 - build-osx: - strategy: - fail-fast: false - matrix: - platform: [ 'arm64', 'x86_64' ] - runs-on: ${{ matrix.platform == 'arm64' && 'macos-14' || 'macos-13' }} - env: - HOMEBREW_NO_AUTO_UPDATE: 1 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - cache-dependency-path: cli/go.sum - go-version-file: cli/go.mod - - name: Configure Version - run: | - if [ "${GITHUB_REF_TYPE}" == "tag" ]; then - export VERSION=${GITHUB_REF_NAME:1} - else - export VERSION=${GITHUB_SHA} - fi - - echo "VERSION=${VERSION}" >> "${GITHUB_ENV}" - - name: Configure cache - uses: actions/cache@v4 - with: - path: dist - key: ${{ matrix.platform }}-${{ hashFiles('cli/*.mod') }} - - name: Run doctor - run: | - export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}" - BUILD=no cli/build-php.sh - - name: Build php - run: | - export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}" - cli/build-php.sh - - name: Build cli - run: | - export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}" - cd cli && ./build.sh - - run: ls -lah cli/dist/ - - run: ls -lah dist/ || true - - name: Archive artifacts - uses: actions/upload-artifact@v4 - with: - name: dphp-${{ runner.os }}-${{ matrix.platform }} - path: cli/dist/dphp performance-test: name: Performance Test needs: diff --git a/Dockerfile b/Dockerfile index fb320e8b..ad66379f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,21 +29,21 @@ RUN apk update; \ m4 \ make \ pkgconfig \ - php83 \ - php83-common \ - php83-ctype \ - php83-curl \ - php83-dom \ - php83-mbstring \ - php83-openssl \ - php83-pcntl \ - php83-phar \ - php83-posix \ - php83-session \ - php83-sodium \ - php83-tokenizer \ - php83-xml \ - php83-xmlwriter \ + php84 \ + php84-common \ + php84-ctype \ + php84-curl \ + php84-dom \ + php84-mbstring \ + php84-openssl \ + php84-pcntl \ + php84-phar \ + php84-posix \ + php84-session \ + php84-sodium \ + php84-tokenizer \ + php84-xml \ + php84-xmlwriter \ upx \ wget \ xz ; \ @@ -54,6 +54,7 @@ ENV PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dba,dom,exif,fileinfo,fi ENV PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip" WORKDIR /go/src/app +COPY .git /go/src/app/.git COPY cli/build-php.sh . RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) BUILD=no ./build-php.sh RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-php.sh diff --git a/cli/build-php.sh b/cli/build-php.sh deleted file mode 100755 index b68c88ec..00000000 --- a/cli/build-php.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/sh - -# -# Copyright ©2024 Robert Landers -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the “Software”), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT -# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -# - -set -o errexit - -if ! type "git" >/dev/null; then - echo "The \"git\" command must be installed." - exit 1 -fi - -os="$(uname -s | tr '[:upper:]' '[:lower:]')" - -export CFLAGS="$CFLAGS -O2" CXXFLAGS="$CXXFLAGS -O2" - -if [ -z "${PHP_EXTENSIONS}" ]; then - export PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dom,exif,fileinfo,filter,gmp,gd,iconv,igbinary,mbregex,mbstring,opcache,openssl,pcntl,phar,posix,readline,simplexml,sockets,sodium,sysvsem,tokenizer,uv,xml,xmlreader,xmlwriter,zip,zlib" -fi - -if [ -z "${PHP_EXTENSION_LIBS}" ]; then - export PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip" -fi - -if [ -z "${PHP_VERSION}" ]; then - export PHP_VERSION="8.3" -fi - -if [ -z "${FRANKENPHP_VERSION}" ]; then - FRANKENPHP_VERSION="dev" - export FRANKENPHP_VERSION -elif [ -d ".git/" ]; then - CURRENT_REF="$(git rev-parse --abbrev-ref HEAD)" - export CURRENT_REF - - if echo "${FRANKENPHP_VERSION}" | grep -F -q "."; then - # Tag - - # Trim "v" prefix if any - FRANKENPHP_VERSION=${FRANKENPHP_VERSION#v} - export FRANKENPHP_VERSION - - git checkout "v${FRANKENPHP_VERSION}" - else - git checkout "${FRANKENPHP_VERSION}" - fi -fi - -if [ -n "${CLEAN}" ]; then - rm -Rf dist/ - go clean -cache -fi - -# Build libphp if necessary -if [ -f "dist/static-php-cli/buildroot/lib/libphp.a" ]; then - cd dist/static-php-cli -else - mkdir -p dist/ - cd dist/ - - if [ -d "static-php-cli/" ]; then - cd static-php-cli/ - git pull - else - git clone --depth 1 --branch main https://github.com/crazywhalecc/static-php-cli - cd static-php-cli/ - fi - - if type "brew" >/dev/null; then - if ! type "composer" >/dev/null; then - packages="composer" - fi - if ! type "go" >/dev/null; then - packages="${packages} go" - fi - if [ -n "${RELEASE}" ] && ! type "gh" >/dev/null; then - packages="${packages} gh" - fi - - if [ -n "${packages}" ]; then - # shellcheck disable=SC2086 - brew install --formula --quiet ${packages} - fi - fi - - composer install --no-dev -a - - if [ "${os}" = "linux" ]; then - extraOpts="--disable-opcache-jit -I "memory_limit=2G" -I "opcache.enable_cli=1" -I "opcache.enable=1"" - echo "" - fi - - if [ -n "${DEBUG_SYMBOLS}" ]; then - extraOpts="${extraOpts} --no-strip" - fi - - ./bin/spc doctor - ./bin/spc fetch --with-php="${PHP_VERSION}" --for-extensions="${PHP_EXTENSIONS}" - # the Brotli library must always be built as it is required by http://github.com/dunglas/caddy-cbrotli - # shellcheck disable=SC2086 - - if [ -z $BUILD ]; then - ./bin/spc build --debug --enable-zts --build-embed ${extraOpts} "${PHP_EXTENSIONS}" --with-libs="brotli,${PHP_EXTENSION_LIBS}" - fi -fi diff --git a/cli/build.sh b/cli/build.sh deleted file mode 100755 index 1b70c12e..00000000 --- a/cli/build.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/sh - -# -# Copyright ©2024 Robert Landers -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the “Software”), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT -# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -# - -set -o errexit - -if ! type "git" >/dev/null; then - echo "The \"git\" command must be installed." - exit 1 -fi - -cd ../dist/static-php-cli - -arch="$(uname -m)" -os="$(uname -s | tr '[:upper:]' '[:lower:]')" -md5binary="md5sum" -if [ "${os}" = "darwin" ]; then - os="mac" - md5binary="md5 -q" -fi - -if [ -z "${PHP_EXTENSIONS}" ]; then - export PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dom,exif,fileinfo,filter,gmp,gd,iconv,igbinary,mbregex,mbstring,opcache,openssl,pcntl,phar,posix,readline,simplexml,sockets,sodium,sysvsem,tokenizer,uuid,uv,xml,xmlreader,xmlwriter,zip,zlib" -fi - -if [ -z "${PHP_EXTENSION_LIBS}" ]; then - export PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip" -fi - -if [ -z "${PHP_VERSION}" ]; then - export PHP_VERSION="8.3" -fi - -if [ -z "${FRANKENPHP_VERSION}" ]; then - FRANKENPHP_VERSION="dev" - export FRANKENPHP_VERSION -elif [ -d ".git/" ]; then - CURRENT_REF="$(git rev-parse --abbrev-ref HEAD)" - export CURRENT_REF - - if echo "${FRANKENPHP_VERSION}" | grep -F -q "."; then - # Tag - - # Trim "v" prefix if any - FRANKENPHP_VERSION=${FRANKENPHP_VERSION#v} - export FRANKENPHP_VERSION - - git checkout "v${FRANKENPHP_VERSION}" - else - git checkout "${FRANKENPHP_VERSION}" - fi -fi - -bin="dphp" - -if [ -n "${CLEAN}" ]; then - rm -Rf dist/ - go clean -cache -fi - -CGO_CFLAGS="-O2 -DFRANKENPHP_VERSION=${FRANKENPHP_VERSION} -I${PWD}/buildroot/include/ $(./buildroot/bin/php-config --includes | sed s\#-I/\#-I"${PWD}"/buildroot/\#g)" -if [ -n "${DEBUG_SYMBOLS}" ]; then - CGO_CFLAGS="-g ${CGO_CFLAGS}" -fi -export CGO_CFLAGS - -if [ "${os}" = "mac" ]; then - export CGO_LDFLAGS="-framework CoreFoundation -framework SystemConfiguration" -fi - -CGO_LDFLAGS="${CGO_LDFLAGS} ${PWD}/buildroot/lib/libbrotlicommon.a ${PWD}/buildroot/lib/libbrotlienc.a ${PWD}/buildroot/lib/libbrotlidec.a $(./buildroot/bin/php-config --ldflags) $(./buildroot/bin/php-config --libs) -lstdc++ -lbrotlidec -lssl -lcrypto -lbrotlienc -lbrotlicommon" -export CGO_LDFLAGS - -LIBPHP_VERSION="$(./buildroot/bin/php-config --version)" -export LIBPHP_VERSION - -cd ../../cli - -VERSION="$(git describe --tags --always)" -if git status --porcelain | grep -q "cli"; then - VERSION="$VERSION-dirty" -fi - -# Embed PHP app, if any -if [ -n "${EMBED}" ] && [ -d "${EMBED}" ]; then - tar -cf app.tar -C "${EMBED}" . - ${md5binary} app.tar >app_checksum.txt -fi - -if [ "${os}" = "linux" ]; then - extraExtldflags="-Wl,-z,stack-size=0x80000" -fi - -if [ -z "${DEBUG_SYMBOLS}" ]; then - extraLdflags="-w -s -race" -fi - -env -go env -go get durable_php -go build -buildmode=pie -tags "cgo netgo nats osusergo static_build nowatcher" -ldflags "-linkmode=external -extldflags '-static-pie ${extraExtldflags}' ${extraLdflags} -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP ${FRANKENPHP_VERSION} PHP ${LIBPHP_VERSION} go_durable_php' -X 'main.version=$VERSION'" -o "dist/${bin}" durable_php - -if [ -d "${EMBED}" ]; then - truncate -s 0 app.tar - truncate -s 0 app_checksum.txt -fi - -if [ -z "${NO_COMPRESS}" ]; then - if type "upx" >/dev/null; then - #upx --best "dist/${bin}" - echo "would compress" - fi -fi - -"dist/${bin}" version From 837975a1abafcf13eaedacd9680f919acb1e83fe Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 19:56:05 +0200 Subject: [PATCH 49/51] do not error on missing a heartbeat Signed-off-by: Robert Landers --- cli/lib/consumer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go index 87311a67..8adeddc2 100644 --- a/cli/lib/consumer.go +++ b/cli/lib/consumer.go @@ -25,7 +25,7 @@ func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config. panic(err) } - iter, err := consumer.Messages(jetstream.PullMaxMessages(1)) + iter, err := consumer.Messages(jetstream.PullMaxMessages(1), jetstream.WithMessagesErrOnMissingHeartbeat(false)) if err != nil { panic(err) } From a8b3f693361c934b29562e5d3f89541bf3860c3a Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 20:31:34 +0200 Subject: [PATCH 50/51] use php 8.4 for unit tests Signed-off-by: Robert Landers --- .github/workflows/Test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test.yaml b/.github/workflows/Test.yaml index 3dfbffd7..6a635a43 100644 --- a/.github/workflows/Test.yaml +++ b/.github/workflows/Test.yaml @@ -17,7 +17,7 @@ jobs: env: runner: self-hosted with: - php-version: '8.3' + php-version: '8.4' extensions: pcntl, xdebug tools: composer - name: Get composer cache directory From 8e47c43534ae4cd8b8f8ae2c980a8efb998cea78 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 30 Jul 2025 23:44:53 +0200 Subject: [PATCH 51/51] remove arm as a target for now Signed-off-by: Robert Landers --- .github/workflows/build-cli.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-cli.yaml b/.github/workflows/build-cli.yaml index ff3551ee..a823a20c 100644 --- a/.github/workflows/build-cli.yaml +++ b/.github/workflows/build-cli.yaml @@ -65,7 +65,7 @@ jobs: builder: ${{ steps.buildx.outputs.name }} cache-from: type=gha,scope=image cache-to: type=gha,mode=max,scope=image - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 - name: Build Test Image uses: docker/build-push-action@v6 with: