diff --git a/go.mod b/go.mod index 3ae5b7fe..c6bf970c 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 - github.com/hashicorp/go-tfe v1.105.0 + github.com/hashicorp/go-tfe v1.106.0 github.com/hashicorp/go-version v1.9.0 github.com/hashicorp/vault/api v1.23.0 github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf @@ -61,14 +61,13 @@ require ( github.com/kardianos/service v1.2.4 github.com/microsoftgraph/msgraph-sdk-go v1.98.0 github.com/mitchellh/mapstructure v1.5.0 - github.com/nexus-rpc/sdk-go v0.6.0 github.com/okta/okta-sdk-golang/v2 v2.20.0 github.com/posthog/posthog-go v1.12.5 github.com/senseyeio/duration v0.0.0-20180430131211-7c2a214ada46 github.com/serverlessworkflow/sdk-go/v3 v3.2.0 github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68 github.com/sirupsen/logrus v1.9.4 - github.com/slack-go/slack v0.23.0 + github.com/slack-go/slack v0.23.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -82,23 +81,23 @@ require ( go.opentelemetry.io/otel/log v0.19.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/log v0.19.0 - go.temporal.io/api v1.62.11 + go.temporal.io/api v1.62.12 go.temporal.io/sdk v1.43.0 golang.org/x/crypto v0.51.0 golang.org/x/net v0.54.0 golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.37.0 - google.golang.org/api v0.278.0 - google.golang.org/genai v1.56.0 - google.golang.org/grpc v1.81.0 + google.golang.org/api v0.279.0 + google.golang.org/genai v1.57.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 - k8s.io/api v0.36.0 - k8s.io/apimachinery v0.36.0 - k8s.io/client-go v0.36.0 + k8s.io/api v0.36.1 + k8s.io/apimachinery v0.36.1 + k8s.io/client-go v0.36.1 software.sslmate.com/src/go-pkcs12 v0.7.1 ) @@ -109,10 +108,10 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/iam v1.11.0 // indirect - cloud.google.com/go/longrunning v0.13.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -254,7 +253,7 @@ require ( github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect - github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 // indirect + github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -269,8 +268,9 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.12 // indirect + github.com/nexus-rpc/sdk-go v0.6.0 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/patrickmn/go-cache v0.0.0-20180815053127-5633e0862627 // indirect @@ -280,7 +280,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/robfig/cron v1.2.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -293,7 +293,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect github.com/stretchr/objx v0.5.3 // indirect - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -326,16 +326,16 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect - google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect + k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 // indirect k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2208e3b3..a7543d25 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/longrunning v0.13.0 h1:dUfqF8y0bHOeZzF5+tKPZ6RBCeEEDOejvwGwENv/eEc= -cloud.google.com/go/longrunning v0.13.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0= cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= @@ -40,8 +40,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k= @@ -401,8 +401,8 @@ github.com/hashicorp/go-slug v1.0.0 h1:aOwhQ1fIbyRAUdBDzXZK2LVmsFFQYuuvJhOM8X9XW github.com/hashicorp/go-slug v1.0.0/go.mod h1:Zxkkl8/LfXmhxZO3fLXQUCy3MVXAJK9pybY8WoDPgvs= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/go-tfe v1.105.0 h1:PXuWC9EWz+5sG/EC+tJO2i+lRamAe+A6tKt2XUImhPs= -github.com/hashicorp/go-tfe v1.105.0/go.mod h1:d8js2OmMnCq58gEh26mCS81nD8Aj7HmG6IO1b80gM78= +github.com/hashicorp/go-tfe v1.106.0 h1:qZdMWGEc2wSf87wo6bmamvWOzvhphyB78WJJyPMHhjo= +github.com/hashicorp/go-tfe v1.106.0/go.mod h1:d8js2OmMnCq58gEh26mCS81nD8Aj7HmG6IO1b80gM78= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= @@ -491,8 +491,8 @@ github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfd github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= github.com/microsoftgraph/msgraph-sdk-go v1.98.0 h1:95oYciFn5yTs4QBBntViNWTPaDVI1u5jJnpOUdVavWY= github.com/microsoftgraph/msgraph-sdk-go v1.98.0/go.mod h1:NMIFoKu7IVAerNRDjkZn7bxeiy55KZxQyneYqzH4+dQ= -github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= -github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1 h1:k3YIaJm57ufoEX0KdsEY4l1X9BAMxEqrwr4a7WMRDzY= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1/go.mod h1:yNqPNhXee2w9cZzkJW5mL1utVMSInsQSo/TyEB5sup8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -529,10 +529,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nexus-rpc/sdk-go v0.6.0 h1:QRgnP2zTbxEbiyWG/aXH8uSC5LV/Mg1fqb19jb4DBlo= github.com/nexus-rpc/sdk-go v0.6.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= -github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= -github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/okta/okta-sdk-golang/v2 v2.20.0 h1:EDKM+uOPfihOMNwgHMdno+NAsIfyXkVnoFAYVPay0YU= github.com/okta/okta-sdk-golang/v2 v2.20.0/go.mod h1:FMy5hN5G8Rd/VoS0XrfyPPhIfOVo78ZK7lvwiQRS2+U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -560,8 +560,8 @@ github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQr github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= @@ -592,8 +592,8 @@ github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68 h1:EW/NT+L github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68/go.mod h1:/trShGwjho17PsOcwG8PT6QoQ2HnZUooZX625+7qZ20= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/slack-go/slack v0.23.0 h1:PTMIHTKJNuA+jVh0BNuE52ntdA7FAxzSqWAdXl5rGa8= -github.com/slack-go/slack v0.23.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= +github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= +github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -632,12 +632,10 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -703,8 +701,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.temporal.io/api v1.62.11 h1:MWDaooDvOJCIRb1atqeZX2ErDPNTsNc3/mMEVEvvaVU= -go.temporal.io/api v1.62.11/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/api v1.62.12 h1:627rVnItegQmrszg1bH4vfyc/1uNo5qCereCNkvZefw= +go.temporal.io/api v1.62.12/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= go.temporal.io/sdk v1.43.0 h1:jHX/T2ZyBVjAtpQ/69NoMS6a+J0CpJAe+naqSB1gkvY= go.temporal.io/sdk v1.43.0/go.mod h1:w9XuJzV25JhnJqUzxJWJISpp5q/EyeCtRKHvhW3lIoQ= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -815,26 +813,26 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= -google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= -google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE= -google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU= -google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA= -google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM= +google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -865,16 +863,16 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= -k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= -k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= -k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= -k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= -k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ= -k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 h1:xs2ux1MvyrOdfKwS3vuFWrGuLgDOHk6id975Twx2Jss= +k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/common/client.go b/internal/common/client.go index 172fe699..a305dd12 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -2,14 +2,31 @@ package common import ( "crypto/sha256" + "os" + "strings" "github.com/denisbrodbeck/machineid" "github.com/google/uuid" ) +const clientIdentifierEnvVar = "THAND_AGENT_ID" + // GetClientIdentifier returns a UUID that uniquely identifies this system. // It uses the machine's hardware ID to generate a consistent, system-specific UUID. func GetClientIdentifier() uuid.UUID { + // If set, this env var overrides the default machine-derived identifier. + // + // Accepts: + // - a UUID string (returned as-is) + // - any non-empty string (hashed deterministically to a UUID) + if value := strings.TrimSpace(os.Getenv(clientIdentifierEnvVar)); value != "" { + if parsed, err := uuid.Parse(value); err == nil { + return parsed + } + + hash := sha256.Sum256([]byte(value)) + return uuid.UUID(hash[:16]) + } // TODO(hugh): Check if the thand.io config exists and use that for an identifier. diff --git a/internal/common/client_test.go b/internal/common/client_test.go new file mode 100644 index 00000000..482cb68b --- /dev/null +++ b/internal/common/client_test.go @@ -0,0 +1,35 @@ +package common + +import ( + "crypto/sha256" + "testing" + + "github.com/google/uuid" +) + +func TestGetClientIdentifier_UsesEnvUUID(t *testing.T) { + expected := uuid.New() + t.Setenv(clientIdentifierEnvVar, expected.String()) + + got := GetClientIdentifier() + if got != expected { + t.Fatalf("GetClientIdentifier() = %s, expected %s", got.String(), expected.String()) + } +} + +func TestGetClientIdentifier_UsesEnvStringHash(t *testing.T) { + value := "not-a-uuid-but-still-unique" + t.Setenv(clientIdentifierEnvVar, value) + + got1 := GetClientIdentifier() + got2 := GetClientIdentifier() + if got1 != got2 { + t.Fatalf("expected deterministic UUID for env override; got %s then %s", got1.String(), got2.String()) + } + + hash := sha256.Sum256([]byte(value)) + expected := uuid.UUID(hash[:16]) + if got1 != expected { + t.Fatalf("GetClientIdentifier() = %s, expected %s", got1.String(), expected.String()) + } +} diff --git a/internal/config/providers.go b/internal/config/providers.go index 747ffb8a..d6aabaf2 100644 --- a/internal/config/providers.go +++ b/internal/config/providers.go @@ -10,6 +10,7 @@ import ( "github.com/thand-io/agent/internal/config/environment" "github.com/thand-io/agent/internal/models" "github.com/thand-io/agent/internal/providers" + sdkConstants "github.com/thand-io/agent/sdk/constants" providerSdk "github.com/thand-io/agent/sdk/providers" "go.temporal.io/sdk/workflow" @@ -190,7 +191,7 @@ func (c *Config) InitializeProviders() error { if c.IsServer() { _ = c.GetServices() if err := c.SetupTemporal(); err != nil { - return fmt.Errorf("setting up temporal services: %w", err) + return fmt.Errorf("failed to set up temporal services: %w", err) } } @@ -239,25 +240,29 @@ func (c *Config) InitializeProviders() error { models.ProviderCapabilityRoles, models.ProviderCapabilityPermissions, models.ProviderCapabilityTenants, + models.ProviderCapabilityNotifier, + models.ProviderCapabilityProvisioning, ) { logrus.Infoln("Provider", result.key, "supports RBAC/Identities capabilities") - // Register provider workflows and activities with Temporal if available - if c.IsServer() { + // If a server or agent register all capabilities depending on their runtime - if c.GetServices() != nil && c.GetServices().HasTemporal() { + if c.GetServices() != nil && c.GetServices().HasTemporal() { - logrus.Infoln("Registering Temporal workflows/activities for provider", result.key) + logrus.Infoln("Registering Temporal workflows/activities for provider", result.key) - temporalService := c.GetServices().GetTemporal() + temporalService := c.GetServices().GetTemporal() - worker := temporalService.GetWorker() + worker := temporalService.GetWorker() - if worker == nil { - logrus.Errorln("Temporal client is configured but worker is nil, cannot register workflows/activities for provider", result.key) - continue - } + if worker == nil { + logrus.Errorln("Temporal client is configured but worker is nil, cannot register workflows/activities for provider", result.key) + continue + } + + // Only sync jobs can happen on the server + if c.IsServer() { syncWorkflowName := models.CreateTemporalProviderWorkflowName( providerResult.GetIdentifier(), @@ -277,75 +282,146 @@ func (c *Config) InitializeProviders() error { VersioningBehavior: workflow.VersioningBehaviorPinned, }, ) + } + + logrus.Info("Registering default activities for provider", providerResult.GetName()) + + // Check the provider capability is either agent or server + providerCapabilities := providerResult.GetCapabilities() - if providerResult.HasCapability(models.ProviderCapabilityProvisioning) { - - authWorkflowName := models.CreateTemporalProviderWorkflowName( - providerResult.GetIdentifier(), - models.TemporalAuthorizeRoleWorkflowName) - - logrus.WithFields(logrus.Fields{ - "workflow": authWorkflowName, - "provider": providerResult.GetIdentifier(), - }).Infoln("Registering provider authorize role workflow with name", authWorkflowName) - - // Register the provider-specific authorize and revoke role workflows. - // These are closure-based: they capture the live provider instance so the - // child workflow can call provider.AuthorizeRole / RevokeRole with a - // full workflow.Context, allowing providers to dispatch activities, - // use workflow.Go, etc. - worker.RegisterWorkflowWithOptions( - models.CreateProviderAuthorizeRoleWorkflow(providerResult), - workflow.RegisterOptions{ - Name: authWorkflowName, - VersioningBehavior: workflow.VersioningBehaviorPinned, - }, - ) - - revokeWorkflowName := models.CreateTemporalProviderWorkflowName( - providerResult.GetIdentifier(), - models.TemporalRevokeRoleWorkflowName) - - logrus.WithFields(logrus.Fields{ - "workflow": revokeWorkflowName, - "provider": providerResult.GetIdentifier(), - }).Infoln("Registering provider revoke role workflow with name", revokeWorkflowName) - - worker.RegisterWorkflowWithOptions( - models.CreateProviderRevokeRoleWorkflow(providerResult), - workflow.RegisterOptions{ - Name: revokeWorkflowName, - VersioningBehavior: workflow.VersioningBehaviorPinned, - }, - ) + // We always register the provider on the server + // so that it can re-set the task queue if needed. + // only register on agent/client if its needed. + + // Register the provisioning capability + if providerCapabilities != nil && + providerCapabilities.Provisioning.Enabled && + (providerCapabilities.Provisioning.Runtime == c.GetMode() || (providerCapabilities.Provisioning.Runtime == sdkConstants.ModeAgent && c.IsClient()) || c.IsServer()) { + + authWorkflowName := models.CreateTemporalProviderWorkflowName( + providerResult.GetIdentifier(), + models.TemporalAuthorizeRoleWorkflowName) + + logrus.WithFields(logrus.Fields{ + "workflow": authWorkflowName, + "provider": providerResult.GetIdentifier(), + }).Infoln("Registering provider authorize role workflow with name", authWorkflowName) + + // Register the provider-specific authorize and revoke role workflows. + // These are closure-based: they capture the live provider instance so the + // child workflow can call provider.AuthorizeRole / RevokeRole with a + // full workflow.Context, allowing providers to dispatch activities, + // use workflow.Go, etc. + worker.RegisterWorkflowWithOptions( + models.CreateProviderAuthorizeRoleWorkflow(providerResult), + workflow.RegisterOptions{ + Name: authWorkflowName, + VersioningBehavior: workflow.VersioningBehaviorPinned, + }, + ) + + revokeWorkflowName := models.CreateTemporalProviderWorkflowName( + providerResult.GetIdentifier(), + models.TemporalRevokeRoleWorkflowName) + + logrus.WithFields(logrus.Fields{ + "workflow": revokeWorkflowName, + "provider": providerResult.GetIdentifier(), + }).Infoln("Registering provider revoke role workflow with name", revokeWorkflowName) + + worker.RegisterWorkflowWithOptions( + models.CreateProviderRevokeRoleWorkflow(providerResult), + workflow.RegisterOptions{ + Name: revokeWorkflowName, + VersioningBehavior: workflow.VersioningBehaviorPinned, + }, + ) + } else { + provisioningEnabled := false + provisioningRuntime := sdkConstants.Mode("") + if providerCapabilities != nil && providerCapabilities.Provisioning != nil { + provisioningEnabled = providerCapabilities.Provisioning.Enabled + provisioningRuntime = providerCapabilities.Provisioning.Runtime } - // Register all custom provider workflows - workflowsRegistry := providerResult.RegisterWorkflows() - if workflowsRegistry != nil { - logrus.Infoln("Registering Temporal workflows for provider", result.key) - worker.RegisterWorkflow(workflowsRegistry) + logrus.WithFields(logrus.Fields{ + "provider": result.key, + "mode": c.GetMode(), + "provisioning.enabled": provisioningEnabled, + "provisioning.runtime": provisioningRuntime, + }).Infoln("Skipping provisioning workflow registration: provider does not support provisioning in this runtime") + } + + // Register the notification capability + if providerCapabilities != nil && + providerCapabilities.Notifier.Enabled && + ((providerCapabilities.Notifier.Runtime == c.GetMode()) || (providerCapabilities.Notifier.Runtime == sdkConstants.ModeAgent && c.IsClient()) || c.IsServer()) { + + notifierWorkflowName := models.CreateTemporalProviderWorkflowName( + providerResult.GetIdentifier(), + models.TemporalNotifyWorkflowName) + + logrus.WithFields(logrus.Fields{ + "workflow": notifierWorkflowName, + "provider": providerResult.GetIdentifier(), + }).Infoln("Registering provider notify workflow with name", notifierWorkflowName) + + worker.RegisterWorkflowWithOptions( + models.CreateProviderNotifyWorkflow(providerResult), + workflow.RegisterOptions{ + Name: notifierWorkflowName, + VersioningBehavior: workflow.VersioningBehaviorPinned, + }, + ) + } else { + notifierEnabled := false + notifierRuntime := sdkConstants.Mode("") + if providerCapabilities != nil && providerCapabilities.Notifier != nil { + notifierEnabled = providerCapabilities.Notifier.Enabled + notifierRuntime = providerCapabilities.Notifier.Runtime } + logrus.WithFields(logrus.Fields{ + "provider": result.key, + "mode": c.GetMode(), + "notifier.enabled": notifierEnabled, + "notifier.runtime": notifierRuntime, + }).Infoln("Skipping notify workflow registration: provider does not support notifier in this runtime") + } + + // Register all custom provider workflows + workflowsRegistry := providerResult.RegisterWorkflows(c.GetMode()) + if workflowsRegistry != nil { + logrus.Infoln("Registering Temporal workflows for provider", result.key) + worker.RegisterWorkflow(workflowsRegistry) + } + + logrus.Infoln("Finished registering Temporal workflows/activities for provider", result.key) + + // Register default provider activities + err := models.RegisterProviderActivities(temporalService, providerResult, c) + if err != nil { + logrus.WithError(err).Errorln("Failed to register default activities for provider:", result.key) + continue + } + + logrus.Infoln("Registered default activities for provider", providerResult.GetName()) - // Register default provider activities - err := models.RegisterProviderActivities(temporalService, providerResult, c) + customActivities := providerResult.RegisterActivities(c.GetMode()) + if customActivities != nil { + + logrus.Infoln("Registering custom Temporal activities for provider", result.key) + + // Now register any custom activities defined by the provider + err = models.RegisterActivities( + temporalService, + providerResult.GetIdentifier(), + customActivities, + ) if err != nil { - logrus.WithError(err).Errorln("Failed to register default activities for provider:", result.key) + logrus.WithError(err).Errorln("Failed to register custom activities for provider:", result.key) continue } - - customActivities := providerResult.RegisterActivities() - if customActivities != nil { - // Now register any custom activities defined by the provider - err = models.RegisterActivities( - temporalService, - providerResult.GetIdentifier(), - customActivities, - ) - if err != nil { - logrus.WithError(err).Errorln("Failed to register custom activities for provider:", result.key) - continue - } - } + } else { + logrus.Infoln("No custom activities to register for provider", result.key) } logrus.Infoln("Synchronizing provider", result.key) @@ -358,7 +434,7 @@ func (c *Config) InitializeProviders() error { } } else { // Provider doesn't have RBAC/Identity capabilities, no sync needed - result.provider.SetReady() + providerResult.SetReady() } // The provider returned from the goroutine already has the client set @@ -425,7 +501,8 @@ func (c *Config) initializeSingleProvider(providerKey string, p *models.Provider // getProviderImplementation returns the appropriate provider implementation based on config mode func (c *Config) getProviderImplementation(providerKey string, providerName string) (models.Provider, error) { - if c.IsServer() || c.IsAgent() { + // TODO + if c.IsServer() || c.IsAgent() || c.IsClient() { return providers.CreateInstance(strings.ToLower(providerName)) } diff --git a/internal/config/services/client.go b/internal/config/services/client.go index c3c36204..f4cd5798 100644 --- a/internal/config/services/client.go +++ b/internal/config/services/client.go @@ -5,9 +5,9 @@ import ( "sync" "github.com/sirupsen/logrus" + "github.com/thand-io/agent/internal/common" "github.com/thand-io/agent/internal/config/services/temporal" "github.com/thand-io/agent/internal/models" - "github.com/thand-io/agent/internal/sessions" ) type localClient struct { @@ -371,45 +371,18 @@ func (e *localClient) ReloadTemporal() error { e.mu.Unlock() } - // Client mode NEVER starts Temporal - if e.config.IsClient() { - logrus.Info("Skipping Temporal initialization in client mode") - return nil - } - logrus.Infof("Initializing temporal...") - // Determine identities based on mode - environment := e.config.GetEnvironment() - identities := []string{environment.GetIdentifier()} - - if e.config.IsAgent() { - // Agent mode: query session manager for all active identities + hostname - sessionMgr := sessions.GetSessionManager() - loginServerName := e.config.GetLoginServerHostname() - - loginServer, err := sessionMgr.GetLoginServer(loginServerName) - - if err != nil { - logrus.WithError(err).Warn("Failed to get login server, using hostname identity only") - } else { - activeSessions := loginServer.GetSessions() - - // Add active session providers as identities - for providerName, session := range activeSessions { - if !session.IsExpired() { - identities = append(identities, providerName) - logrus.WithFields(logrus.Fields{ - "provider": providerName, - "expiry": session.Expiry, - }).Debug("Adding active session identity to worker pool") - } - } - } - - logrus.WithField("identities", identities).Info("Configuring Temporal workers for agent mode") + // Determine task queue based on mode: + // - Server: shared default task queue + // - Agent / Client: per-client task queue derived from the client identifier + taskQueue := temporal.DefaultTaskQueue + if e.config.IsAgent() || e.config.IsClient() { + taskQueue = common.GetClientIdentifier().String() } + logrus.WithField("taskQueue", taskQueue).Info("Configuring Temporal worker") + // Get Temporal config from services servicesConfig := e.config.GetServicesConfig() @@ -422,7 +395,7 @@ func (e *localClient) ReloadTemporal() error { temporalService := temporal.NewTemporalClient( temporalConfig, e.vault, - identities..., + taskQueue, ) if err := temporalService.Initialize(); err != nil { logrus.Errorf("Error initializing temporal: %v", err) diff --git a/internal/config/services/temporal/auth_apikey_test.go b/internal/config/services/temporal/auth_apikey_test.go index 11713036..65a44488 100644 --- a/internal/config/services/temporal/auth_apikey_test.go +++ b/internal/config/services/temporal/auth_apikey_test.go @@ -95,6 +95,7 @@ func TestHasAPIKeyAuth(t *testing.T) { temporalClient := NewTemporalClient( config, nil, + "test-identity", ) got := temporalClient.hasAPIKeyAuth() diff --git a/internal/config/services/temporal/auth_mtls_file_test.go b/internal/config/services/temporal/auth_mtls_file_test.go index 527c2558..9c89fda4 100644 --- a/internal/config/services/temporal/auth_mtls_file_test.go +++ b/internal/config/services/temporal/auth_mtls_file_test.go @@ -145,6 +145,7 @@ func TestHasMTLSFile(t *testing.T) { temporalClient := NewTemporalClient( config, nil, + "test-identity", ) got := temporalClient.hasMTLSFile() diff --git a/internal/config/services/temporal/auth_mtls_inline_test.go b/internal/config/services/temporal/auth_mtls_inline_test.go index c0bf4d27..0158af58 100644 --- a/internal/config/services/temporal/auth_mtls_inline_test.go +++ b/internal/config/services/temporal/auth_mtls_inline_test.go @@ -129,6 +129,7 @@ func TestHasMTLSInline(t *testing.T) { temporalClient := NewTemporalClient( config, nil, + "test-identity", ) got := temporalClient.hasMTLSInline() diff --git a/internal/config/services/temporal/auth_mtls_vault_test.go b/internal/config/services/temporal/auth_mtls_vault_test.go index 2ce734e4..ef3d22dc 100644 --- a/internal/config/services/temporal/auth_mtls_vault_test.go +++ b/internal/config/services/temporal/auth_mtls_vault_test.go @@ -341,6 +341,7 @@ func TestHasMTLSVault(t *testing.T) { temporalClient := NewTemporalClient( config, nil, + "test-identity", ) got := temporalClient.hasMTLSVault() diff --git a/internal/config/services/temporal/main.go b/internal/config/services/temporal/main.go index abc25290..86886b6f 100644 --- a/internal/config/services/temporal/main.go +++ b/internal/config/services/temporal/main.go @@ -16,19 +16,15 @@ import ( "go.temporal.io/sdk/workflow" ) -// MaxWorkers is the maximum number of identity-specific workers per client. -// A default of 5 is chosen as a conservative limit to balance concurrency with CPU -// and memory usage for typical agent deployments. If agents are expected to manage -// significantly more identities concurrently, this value should be revisited and -// validated under expected load before being increased. -const MaxWorkers = 5 +// DefaultTaskQueue is the shared task queue used by server-mode workers. +const DefaultTaskQueue = "default" type TemporalClient struct { - config *models.TemporalConfig - client client.Client - workers map[string]worker.Worker - identities []string - vault models.VaultImpl + config *models.TemporalConfig + client client.Client + worker worker.Worker + taskQueue string + vault models.VaultImpl mu sync.Mutex readyCh chan struct{} @@ -39,40 +35,27 @@ type TemporalClient struct { func NewTemporalClient( config *models.TemporalConfig, vault models.VaultImpl, - identities ...string, + taskQueue string, ) *TemporalClient { - // Deduplicate identities to prevent orphaned workers - seen := make(map[string]struct{}, len(identities)) - unique := make([]string, 0, len(identities)) - for _, id := range identities { - if _, exists := seen[id]; !exists { - seen[id] = struct{}{} - unique = append(unique, id) - } - } - if len(unique) > MaxWorkers { - unique = unique[:MaxWorkers] - } return &TemporalClient{ - config: config, - identities: unique, - vault: vault, - workers: make(map[string]worker.Worker, len(unique)), - readyCh: make(chan struct{}), + config: config, + taskQueue: taskQueue, + vault: vault, + readyCh: make(chan struct{}), } } func (a *TemporalClient) Initialize() error { - if len(a.identities) == 0 { - return fmt.Errorf("temporal client requires at least one identity") + if len(a.taskQueue) == 0 { + return fmt.Errorf("temporal client requires a task queue") } clientOptions := client.Options{ Logger: newLogrusLogger(), HostPort: a.GetHostPort(), Namespace: a.GetNamespace(), - Identity: a.identities[0], + Identity: a.taskQueue, } // Configure authentication (API key or mTLS) @@ -132,35 +115,26 @@ func (a *TemporalClient) Initialize() error { } } - // Create a worker for each identity (task queue). - // Registration must happen before workers are started. + // Create the single worker for our task queue. + // Registration must happen before the worker is started. a.mu.Lock() defer a.mu.Unlock() - if len(a.workers) > 0 { - logrus.Warn("Temporal workers already started, skipping worker initialization") + if a.worker != nil { + logrus.Warn("Temporal worker already created, skipping worker initialization") return nil } - for _, identity := range a.identities { - newWorker := worker.New( - temporalClient, - identity, - workerOptions, - ) - - a.workers[identity] = newWorker - } - - if len(a.workers) == 0 { - a.markReady() // Unblock any waiters even on failure - return fmt.Errorf("failed to create any Temporal workers") - } + a.worker = worker.New( + temporalClient, + a.taskQueue, + workerOptions, + ) return nil } -// StartWorkers starts all registered Temporal workers. +// StartWorkers starts the registered Temporal worker. // This must be called only after workflow/activity registration is complete. func (c *TemporalClient) StartWorkers() error { c.mu.Lock() @@ -170,39 +144,26 @@ func (c *TemporalClient) StartWorkers() error { return fmt.Errorf("temporal client is not initialized") } - if len(c.workers) == 0 { + if c.worker == nil { c.markReady() - return fmt.Errorf("no Temporal workers configured") + return fmt.Errorf("no Temporal worker configured") } if c.workersStarted { - logrus.Warn("Temporal workers already started, skipping worker startup") + logrus.Warn("Temporal worker already started, skipping worker startup") return nil } buildID := common.GetBuildIdentifier() - startedCount := 0 - - for identity, w := range c.workers { - logrus.WithFields(logrus.Fields{ - "BuildID": buildID, - "taskQueue": identity, - }).Info("Starting Temporal worker") - - if err := w.Start(); err != nil { - logrus.WithError(err). - WithField("taskQueue", identity). - Error("Failed to start temporal worker") - delete(c.workers, identity) - continue - } - startedCount++ - } + logrus.WithFields(logrus.Fields{ + "BuildID": buildID, + "taskQueue": c.taskQueue, + }).Info("Starting Temporal worker") - if startedCount == 0 { + if err := c.worker.Start(); err != nil { c.markReady() - return fmt.Errorf("failed to start any Temporal workers") + return fmt.Errorf("failed to start temporal worker: %w", err) } c.workersStarted = true @@ -219,7 +180,13 @@ func (c *TemporalClient) StartWorkers() error { } func (c *TemporalClient) GetClient() client.Client { - <-c.readyCh + c.mu.Lock() + shouldWait := c.workersStarted && !c.config.DisableVersioning + c.mu.Unlock() + + if shouldWait { + <-c.readyCh + } c.mu.Lock() defer c.mu.Unlock() return c.client @@ -234,41 +201,14 @@ func (c *TemporalClient) HasClient() bool { func (c *TemporalClient) HasWorker() bool { c.mu.Lock() defer c.mu.Unlock() - return len(c.workers) > 0 + return c.worker != nil } -// GetWorker returns a synthetic worker that broadcasts registration calls -// across all (or a filtered subset of) identity-specific workers. -// If identities are provided, only matching workers are included. -// Returns nil if no matching workers are found. -func (c *TemporalClient) GetWorker(identities ...string) worker.Worker { +// GetWorker returns the underlying Temporal worker, or nil if not initialized. +func (c *TemporalClient) GetWorker() worker.Worker { c.mu.Lock() defer c.mu.Unlock() - - if len(c.workers) == 0 { - return nil - } - - // No filter: return all workers - if len(identities) == 0 { - workers := make([]worker.Worker, 0, len(c.workers)) - for _, w := range c.workers { - workers = append(workers, w) - } - return &multiWorker{workers: workers} - } - - // Filtered: return only matching workers - workers := make([]worker.Worker, 0, len(identities)) - for _, id := range identities { - if w, ok := c.workers[id]; ok { - workers = append(workers, w) - } - } - if len(workers) == 0 { - return nil - } - return &multiWorker{workers: workers} + return c.worker } func (c *TemporalClient) GetHostPort() string { @@ -283,17 +223,11 @@ func (c *TemporalClient) GetNamespace() string { } func (c *TemporalClient) GetTaskQueue() string { - if len(c.identities) == 0 { - return "" - } - return c.identities[0] + return c.taskQueue } func (c *TemporalClient) GetIdentity() string { - if len(c.identities) == 0 { - return "" - } - return c.identities[0] + return c.taskQueue } func (c *TemporalClient) IsVersioningDisabled() bool { @@ -341,6 +275,15 @@ func (c *TemporalClient) awaitVersionRegistration(buildID string) { "BuildID": buildID, "DeploymentName": deploymentName, }).Info("Temporal deployment version registered and ready") + + // Promote this build to the deployment's current version so that + // AutoUpgrade workflows (e.g. the long-running system workflows + // in internal/config/temporal_workflows.go) route to this worker + // after a binary upgrade. Without this, a running AutoUpgrade + // workflow would stay on whichever BuildID was current when it + // was last polled and the new worker could not serve its + // queries/updates. Pinned workflows are unaffected. + c.promoteDeploymentToCurrent(ctx, handle, buildID, deploymentName) return } @@ -359,21 +302,55 @@ func (c *TemporalClient) awaitVersionRegistration(buildID string) { } } +// promoteDeploymentToCurrent marks buildID as the current version of the +// worker deployment so AutoUpgrade workflows route their next workflow task +// to this worker. Failures are logged but non-fatal: the worker can still +// serve its own pinned workflows, and a subsequent worker startup will +// retry the promotion. Best-effort; safe to call when this build is +// already current (the server treats it as a no-op). +func (c *TemporalClient) promoteDeploymentToCurrent( + ctx context.Context, + handle client.WorkerDeploymentHandle, + buildID string, + deploymentName string, +) { + _, err := handle.SetCurrentVersion(ctx, client.WorkerDeploymentSetCurrentVersionOptions{ + BuildID: buildID, + // AutoUpgrade workflows are the only consumers of "current" routing + // here. Late-starting workers on the same task queue will register + // their own polls before they take work, so missing-task-queue + // protection isn't useful for our single-queue topology and would + // reject the promotion during a rolling restart of the only worker. + IgnoreMissingTaskQueues: true, + }) + if err != nil { + logrus.WithError(err).WithFields(logrus.Fields{ + "BuildID": buildID, + "DeploymentName": deploymentName, + }).Warn("Failed to promote Temporal deployment to current version; AutoUpgrade workflows may not route to this worker until a future startup") + return + } + logrus.WithFields(logrus.Fields{ + "BuildID": buildID, + "DeploymentName": deploymentName, + }).Info("Promoted Temporal deployment to current version") +} + func (c *TemporalClient) Shutdown() error { c.markReady() // Unblock any waiters c.mu.Lock() defer c.mu.Unlock() - // Stop all workers before closing the client - for id, w := range c.workers { - logrus.WithField("taskQueue", id).Info("Stopping Temporal worker") - w.Stop() + // Stop the worker before closing the client + if c.worker != nil { + logrus.WithField("taskQueue", c.taskQueue).Info("Stopping Temporal worker") + c.worker.Stop() } if c.client != nil { c.client.Close() } - c.workers = nil + c.worker = nil c.client = nil c.workersStarted = false diff --git a/internal/config/services/temporal/multi_worker.go b/internal/config/services/temporal/multi_worker.go deleted file mode 100644 index fa661f23..00000000 --- a/internal/config/services/temporal/multi_worker.go +++ /dev/null @@ -1,78 +0,0 @@ -package temporal - -import ( - "github.com/nexus-rpc/sdk-go/nexus" - "go.temporal.io/sdk/activity" - "go.temporal.io/sdk/worker" - "go.temporal.io/sdk/workflow" -) - -// multiWorker implements worker.Worker by broadcasting registration calls -// across multiple underlying workers. This allows a single GetWorker() call -// to register workflows and activities on all (or a filtered subset of) -// identity-specific task queues. -// -// Lifecycle methods (Start/Run/Stop) are no-ops because TemporalClient -// manages worker lifecycle directly via StartWorkers() and Shutdown(). -type multiWorker struct { - workers []worker.Worker -} - -// Compile-time assertion that multiWorker implements worker.Worker. -var _ worker.Worker = (*multiWorker)(nil) - -// --- WorkflowRegistry --- - -func (m *multiWorker) RegisterWorkflow(w any) { - for _, wr := range m.workers { - wr.RegisterWorkflow(w) - } -} - -func (m *multiWorker) RegisterWorkflowWithOptions(w any, options workflow.RegisterOptions) { - for _, wr := range m.workers { - wr.RegisterWorkflowWithOptions(w, options) - } -} - -func (m *multiWorker) RegisterDynamicWorkflow(w any, options workflow.DynamicRegisterOptions) { - for _, wr := range m.workers { - wr.RegisterDynamicWorkflow(w, options) - } -} - -// --- ActivityRegistry --- - -func (m *multiWorker) RegisterActivity(a any) { - for _, wr := range m.workers { - wr.RegisterActivity(a) - } -} - -func (m *multiWorker) RegisterActivityWithOptions(a any, options activity.RegisterOptions) { - for _, wr := range m.workers { - wr.RegisterActivityWithOptions(a, options) - } -} - -func (m *multiWorker) RegisterDynamicActivity(a any, options activity.DynamicRegisterOptions) { - for _, wr := range m.workers { - wr.RegisterDynamicActivity(a, options) - } -} - -// --- NexusServiceRegistry --- - -func (m *multiWorker) RegisterNexusService(s *nexus.Service) { - for _, wr := range m.workers { - wr.RegisterNexusService(s) - } -} - -// --- Lifecycle (no-ops; managed by TemporalClient) --- - -func (m *multiWorker) Start() error { return nil } - -func (m *multiWorker) Run(interruptCh <-chan any) error { return nil } - -func (m *multiWorker) Stop() {} diff --git a/internal/config/services/temporal/readiness_test.go b/internal/config/services/temporal/readiness_test.go index daaa48b5..fee3e4d8 100644 --- a/internal/config/services/temporal/readiness_test.go +++ b/internal/config/services/temporal/readiness_test.go @@ -26,9 +26,11 @@ func newTestClient() *TemporalClient { ) } -func TestGetClient_BlocksUntilReady(t *testing.T) { +func TestGetClient_BlocksUntilReady_WhenVersioningEnabledAndWorkersStarted(t *testing.T) { t.Parallel() tc := newTestClient() + tc.config.DisableVersioning = false + tc.workersStarted = true done := make(chan struct{}) go func() { @@ -57,6 +59,8 @@ func TestGetClient_BlocksUntilReady(t *testing.T) { func TestGetClient_ImmediateWhenAlreadyReady(t *testing.T) { t.Parallel() tc := newTestClient() + tc.config.DisableVersioning = false + tc.workersStarted = true tc.markReady() done := make(chan struct{}) @@ -76,6 +80,8 @@ func TestGetClient_ImmediateWhenAlreadyReady(t *testing.T) { func TestGetClient_MultipleWaiters(t *testing.T) { t.Parallel() tc := newTestClient() + tc.config.DisableVersioning = false + tc.workersStarted = true const n = 10 var wg sync.WaitGroup @@ -157,6 +163,8 @@ func TestMarkReady_ConcurrentSafe(t *testing.T) { func TestShutdown_UnblocksGetClient(t *testing.T) { t.Parallel() tc := newTestClient() + tc.config.DisableVersioning = false + tc.workersStarted = true done := make(chan struct{}) go func() { @@ -184,6 +192,8 @@ func TestShutdown_UnblocksGetClient(t *testing.T) { func TestGetClient_ShutdownNoDeadlock(t *testing.T) { t.Parallel() tc := newTestClient() + tc.config.DisableVersioning = false + tc.workersStarted = true // Launch many GetClient callers const n = 20 diff --git a/internal/config/temporal.go b/internal/config/temporal.go index 5f4d4777..b9be2c19 100644 --- a/internal/config/temporal.go +++ b/internal/config/temporal.go @@ -1,25 +1,207 @@ package config import ( + "context" "fmt" "github.com/sirupsen/logrus" + "github.com/thand-io/agent/internal/common" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" + "go.temporal.io/api/enums/v1" "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" ) -// Register temporal workflows and activities +// registerTemporalWorkflows registers workflow types with the local worker. +// +// This only registers workflow definitions; it does NOT start any workflow +// runs. Workflow execution is deferred to startSystemWorkflow which must +// run AFTER StartTemporalWorkers — when worker versioning is enabled, the +// pinned deployment version is only "present" on the task queue once the +// worker has polled and Temporal has registered the version, so starting +// a pinned workflow before workers run fails with +// "Pinned version ... is not present in task queue". func (c *Config) registerTemporalWorkflows() error { if c.servicesClient == nil || c.servicesClient.GetTemporal() == nil { return fmt.Errorf("temporal service is not initialized") } - temporalWorker := c.servicesClient.GetTemporal().GetWorker() + temporalService := c.servicesClient.GetTemporal() + temporalWorker := temporalService.GetWorker() if temporalWorker == nil { return fmt.Errorf("temporal worker is not initialized") } + systemID := common.GetClientIdentifier() + + if c.IsServer() { + + logrus.Infoln("Registering server workflow", "workflowId", systemID.String()) + + // AutoUpgrade so the long-running per-system workflow transitions + // to the deployment's current Build ID on the next workflow task + // after a binary upgrade. Pinning would strand a running execution + // on the previous BuildID and break ping/update once that worker + // stops. Any non-deterministic change to the workflow body must be + // guarded with workflow.GetVersion / patches. + temporalWorker.RegisterWorkflowWithOptions( + CreateServerWorkflow(), + workflow.RegisterOptions{ + Name: "server-workflow", + VersioningBehavior: workflow.VersioningBehaviorAutoUpgrade, + }, + ) + + } else if c.IsAgent() || c.IsClient() { + + logrus.Infoln("Registering agent workflow", "workflowId", systemID.String()) + + // AutoUpgrade: see server-workflow comment above. + temporalWorker.RegisterWorkflowWithOptions( + CreateAgentWorkflow(), + workflow.RegisterOptions{ + Name: "agent-workflow", + VersioningBehavior: workflow.VersioningBehaviorAutoUpgrade, + }, + ) + } + + return nil +} + +// StartSystemWorkflow starts the long-running per-system server or agent +// workflow. Must be called AFTER StartTemporalWorkers so that, when worker +// versioning is enabled, the deployment version is registered with the +// Temporal server before we submit a workflow pinned to it. The temporal +// service's GetClient gates on version registration to make this safe. +func (c *Config) StartSystemWorkflow() error { + if c.servicesClient == nil || c.servicesClient.GetTemporal() == nil { + return nil + } + + temporalService := c.servicesClient.GetTemporal() + temporalClient := temporalService.GetClient() + + if temporalClient == nil { + return fmt.Errorf("temporal client is not initialized") + } + + systemID := common.GetClientIdentifier() + + ctx := context.Background() + + startOptions := client.StartWorkflowOptions{ + ID: systemID.String(), + TaskQueue: temporalService.GetTaskQueue(), + WorkflowIDReusePolicy: enums.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE, + WorkflowIDConflictPolicy: enums.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING, + } + + // Note: the system workflows are registered with AutoUpgrade behaviour + // (see registerTemporalWorkflows). We deliberately do NOT apply a + // PinnedVersioningOverride here — that would strand the long-running + // execution on the BuildID that started it and prevent future workers + // (with newer BuildIDs) from serving its workflow tasks, queries, and + // updates. The deployment's "current" version is promoted on worker + // startup (see internal/config/services/temporal), which is what + // AutoUpgrade workflows route to. + + if c.IsServer() { + + if _, err := temporalClient.ExecuteWorkflow( + ctx, + startOptions, + "server-workflow", + ServerWorkflowStart{ + ThandSystemStart: ThandSystemStart{ + Identities: []string{}, + }, + }, + ); err != nil { + logrus.WithError(err).Errorf("Failed to start server workflow: %v", err) + return fmt.Errorf("failed to start server workflow: %w", err) + } + + } else if c.IsAgent() || c.IsClient() { + + agentIdentities := []string{ + "hugh@thand.io", + } + + startInput := AgentWorkflowStart{ + ThandSystemStart: ThandSystemStart{ + Identities: agentIdentities, + }, + } + + logrus.WithFields(logrus.Fields{ + "workflowId": systemID.String(), + "taskQueue": temporalService.GetTaskQueue(), + "identities": agentIdentities, + "identitiesCount": len(agentIdentities), + "conflictPolicy": startOptions.WorkflowIDConflictPolicy.String(), + "reusePolicy": startOptions.WorkflowIDReusePolicy.String(), + "startInputStruct": fmt.Sprintf("%+v", startInput), + }).Info("Starting agent workflow with identities") + + run, err := temporalClient.ExecuteWorkflow( + ctx, + startOptions, + "agent-workflow", + startInput, + ) + if err != nil { + logrus.WithError(err).Errorf("Failed to start agent workflow: %v", err) + return fmt.Errorf("failed to start agent workflow: %w", err) + } + + logrus.WithFields(logrus.Fields{ + "workflowId": run.GetID(), + "runId": run.GetRunID(), + "identities": agentIdentities, + }).Info("Agent workflow start request accepted; pushing identities via update to handle USE_EXISTING reuse") + + // Because WorkflowIDConflictPolicy is USE_EXISTING, the start args + // above are ignored when an agent workflow with the same ID is + // already running. Send an update so the running workflow always + // reflects the latest identities. Dispatched asynchronously so + // startup is not blocked on the round-trip update completion. + go func(workflowID, runID string, identities []string) { + updateCtx := context.Background() + updateHandle, err := temporalClient.UpdateWorkflow(updateCtx, client.UpdateWorkflowOptions{ + WorkflowID: workflowID, + RunID: runID, + UpdateName: models.TemporalSystemUpdateIdentitiesUpdateName, + Args: []any{identities}, + WaitForStage: client.WorkflowUpdateStageCompleted, + }) + if err != nil { + logrus.WithError(err).WithFields(logrus.Fields{ + "workflowId": workflowID, + "runId": runID, + "identities": identities, + }).Warn("Failed to send updateIdentities to agent workflow") + return + } + var updatedIdentities []string + if err := updateHandle.Get(updateCtx, &updatedIdentities); err != nil { + logrus.WithError(err).WithFields(logrus.Fields{ + "workflowId": workflowID, + "runId": runID, + }).Warn("updateIdentities completed with error") + return + } + logrus.WithFields(logrus.Fields{ + "workflowId": workflowID, + "runId": runID, + "updatedIdentities": updatedIdentities, + }).Info("updateIdentities completed successfully") + }(run.GetID(), run.GetRunID(), agentIdentities) + } + return nil } @@ -39,6 +221,24 @@ func (c *Config) registerTemporalActivities() error { config: c, } + logrus.Info("Registering system identifier lookup") + + temporalWorker.RegisterActivityWithOptions( + thandActivities.LookupSystemIdentifier, + activity.RegisterOptions{ + Name: models.TemporalLookupSystemIdentifierActivityName, + }, + ) + + /* + Signal Workflow Activity + */ + temporalWorker.RegisterActivityWithOptions( + thandActivities.SignalWorkflow, + activity.RegisterOptions{ + Name: sdkConstants.TemporalSignalWorkflowActivityName, + }) + if c.HasThandService() { logrus.Info("Registering upstream patching activities for Thand service") diff --git a/internal/config/temporal_activities.go b/internal/config/temporal_activities.go index 43df3169..9505f1bf 100644 --- a/internal/config/temporal_activities.go +++ b/internal/config/temporal_activities.go @@ -8,6 +8,7 @@ import ( "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/activity" "go.temporal.io/sdk/temporal" ) @@ -16,6 +17,149 @@ type thandActivities struct { config *Config } +func (t *thandActivities) SignalWorkflow(ctx context.Context, + workflowId string, + runId string, + signalName string, + signalInput any, +) error { + + services := t.config.GetServices() + + if !services.HasTemporal() { + return temporal.NewNonRetryableApplicationError( + "Temporal service is not configured", + "TemporalServiceNotConfigured", + nil, + ) + } + + if !services.GetTemporal().HasClient() { + return temporal.NewNonRetryableApplicationError( + "Temporal client is not configured", + "TemporalClientNotConfigured", + nil, + ) + } + + log := activity.GetLogger(ctx) + + log.Info("Signaling workflow") + + err := services.GetTemporal().GetClient().SignalWorkflow( + ctx, + workflowId, + runId, + signalName, + signalInput, + ) + + if err != nil { + log.Error("Failed to signal workflow") + return err + } + + return nil + +} + +// This queries both system and agent workflows to get the task queue +// identifier - used in order to figure out where the workflow op should +// run +func (t *thandActivities) LookupSystemIdentifier( + ctx context.Context, + identifier string, +) (string, error) { + + if len(identifier) == 0 { + return "", temporal.NewNonRetryableApplicationError( + "Identifier cannot be empty", + "InvalidIdentifier", + nil, + ) + } + + c := t.config + + log := activity.GetLogger(ctx) + + log.Info("Looking up system identifier in temporal workflows", "identifier", identifier) + + // First we'll query the system workflows to see if there is a match + // if there is a match, we'll return the workflow id which is the same as the system id + + if !c.GetServices().HasTemporal() { + + log.Warn("Thand service is not configured; skipping PatchProviderUpstream activity") + + return "", temporal.NewNonRetryableApplicationError( + "Thand service is not configured", + "ThandServiceNotConfigured", + nil, + ) + } + + temporalService := c.GetServices().GetTemporal() + + if !temporalService.HasClient() { + log.Warn("Thand service is not configured; skipping PatchProviderUpstream activity") + + return "", temporal.NewNonRetryableApplicationError( + "Thand service is not configured", + "ThandServiceNotConfigured", + nil, + ) + } + + // Now lookup + + temporalClient := temporalService.GetClient() + + listResponse, err := temporalClient.ListWorkflow(ctx, &workflowservice.ListWorkflowExecutionsRequest{ + Namespace: temporalService.GetNamespace(), + Query: fmt.Sprintf( + "`identities` in(\"%s\") AND (`WorkflowType`=\"server-workflow\" OR `WorkflowType`=\"agent-workflow\") AND `ExecutionStatus`=\"Running\"", + identifier, + ), + }) + + if err != nil { + log.Error("failed to list workflows", "error", err) + return "", err + } + + log.Info("Found workflows for system identifier", "count", len(listResponse.GetExecutions())) + + for _, workflowExec := range listResponse.GetExecutions() { + + // Send a quick query to see if there is a worker avaliable + // we'll keep going until we find an alive system + + // We'll signal the workflow to see if its alive + + _, err := temporalClient.QueryWorkflow( + ctx, + workflowExec.Execution.GetWorkflowId(), + workflowExec.Execution.GetRunId(), + models.TemporalSystemPingQueryName, + nil, // empty ping + ) + + if err != nil { + log.Info("failed to query workflow", + "workflowId", workflowExec.Execution.GetWorkflowId()) + continue + } + + // Device is alive - we'll query this one + return workflowExec.Execution.WorkflowId, nil + } + + // Re-try the query after a short delay - this is to handle the case where the workflow has just been started and is not yet available in the list query + return "", fmt.Errorf("no active workflows found for identifier: %s", identifier) + +} + // PatchProviderUpstreamDummy is a no-op activity for thand server/agents that are not // configured to use the Thand service func (t *thandActivities) PatchProviderUpstreamDummy( diff --git a/internal/config/temporal_workflows.go b/internal/config/temporal_workflows.go index d912156b..8d561e61 100644 --- a/internal/config/temporal_workflows.go +++ b/internal/config/temporal_workflows.go @@ -1 +1,260 @@ package config + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" + sdkWorkflows "github.com/thand-io/agent/sdk/workflows/manager" + sdkWorkflowsModel "github.com/thand-io/agent/sdk/workflows/models" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" +) + +// This file creates long-running workflows for a given system id. +// +// The server-workflow and agent-workflow registrations use +// VersioningBehaviorAutoUpgrade (see registerTemporalWorkflows). That means +// a running execution will transition to the deployment's new current +// BuildID on its next workflow task after a worker restart, instead of +// being stranded on the BuildID that started it. To keep that transition +// safe across binary upgrades, any change to systemHandler or the +// query/update handlers below that introduces non-deterministic +// behaviour (new commands, reordered awaits, removed/renamed handlers, +// changed selector composition, etc.) MUST be guarded with +// workflow.GetVersion / workflow patches. Adding a new query/update +// handler is safe; removing one is not. + +type ThandSystemStart struct { + Identities []string +} + +type ThandSystemImpl interface { + GetIdentities() []string +} + +type ServerWorkflowStart struct { + ThandSystemStart +} + +func (r *ServerWorkflowStart) GetIdentities() []string { + return r.Identities +} + +type AgentWorkflowStart struct { + ThandSystemStart +} + +func (r *AgentWorkflowStart) GetIdentities() []string { + return r.Identities +} + +type SystemWorkflowShutdown struct { + Identities []string + Reason string + ShutdownAt time.Time +} + +type ServerWorkflowShutdown = SystemWorkflowShutdown +type AgentWorkflowShutdown = SystemWorkflowShutdown + +func CreateServerWorkflow() func(workflow.Context, ServerWorkflowStart) (*ServerWorkflowShutdown, error) { + return func(ctx workflow.Context, req ServerWorkflowStart) (*ServerWorkflowShutdown, error) { + shutdown, err := systemHandler(ctx, &req) + if shutdown == nil { + return nil, err + } + return (*ServerWorkflowShutdown)(shutdown), err + } +} + +func CreateAgentWorkflow() func(workflow.Context, AgentWorkflowStart) (*AgentWorkflowShutdown, error) { + return func(ctx workflow.Context, req AgentWorkflowStart) (*AgentWorkflowShutdown, error) { + shutdown, err := systemHandler(ctx, &req) + if shutdown == nil { + return nil, err + } + return (*AgentWorkflowShutdown)(shutdown), err + } +} + +func systemHandler( + rootCtx workflow.Context, + req ThandSystemImpl, +) (outputShutdown *SystemWorkflowShutdown, outputError error) { + + log := workflow.GetLogger(rootCtx) + log.Info("Primary workflow execution started") + + workflowInfo := workflow.GetInfo(rootCtx) + log.Info("Primary workflow started.", + "WorkflowID", workflowInfo.WorkflowExecution.ID, + "RunID", workflowInfo.WorkflowExecution.RunID, + "BuildID", workflowInfo.GetCurrentBuildID(), + ) + + cancelCtx, cancelHandler := workflow.WithCancel(rootCtx) + + rawIdentities := req.GetIdentities() + log.Info("Workflow start input identities", + "ReqType", fmt.Sprintf("%T", req), + "ReqValue", fmt.Sprintf("%+v", req), + "RawIdentities", rawIdentities, + "RawCount", len(rawIdentities), + ) + + identities := dedupeStringsStable(rawIdentities) + log.Info("Workflow identities after dedupe", + "Identities", identities, + "Count", len(identities), + ) + shutdownReason := "" + var terminationRequest *models.TemporalTerminationRequest + + if err := upsertIdentitiesSearchAttribute(cancelCtx, identities); err != nil { + log.Error("Failed to upsert identities search attribute", "Error", err, "Identities", identities) + return nil, err + } + log.Info("Upserted identities search attribute", "Identities", identities, "Count", len(identities)) + + defer func() { + if r := recover(); r != nil { + outputError = fmt.Errorf("workflow failed: %v", r) + return + } + + if cancelCtx.Err() != nil && (errors.Is(cancelCtx.Err(), context.Canceled) || temporal.IsCanceledError(cancelCtx.Err())) { + outputError = nil + } + log.Info("Workflow cleanup completed.") + }() + + // Query support: ping -> pong + if err := setupSystemQueryHandlers(cancelCtx); err != nil { + log.Error("Failed to set query handler", "Error", err) + return nil, err + } + + // Update handler: update identities (stable de-dupe) + if err := workflow.SetUpdateHandler(cancelCtx, models.TemporalSystemUpdateIdentitiesUpdateName, func(ctx workflow.Context, newIdentities []string) ([]string, error) { + log := workflow.GetLogger(ctx) + identities = dedupeStringsStable(newIdentities) + if err := upsertIdentitiesSearchAttribute(ctx, identities); err != nil { + log.Error("Failed to upsert identities search attribute", "Error", err) + return nil, err + } + log.Info("Identities updated", "Count", len(identities)) + return identities, nil + }); err != nil { + log.Error("Failed to set update handler", "Error", err, "UpdateName", models.TemporalSystemUpdateIdentitiesUpdateName) + return nil, err + } + + // Update handler: request graceful shutdown + if err := workflow.SetUpdateHandler(cancelCtx, models.TemporalSystemShutdownUpdateName, func(ctx workflow.Context, reason string) error { + log := workflow.GetLogger(ctx) + shutdownReason = reason + log.Info("Shutdown requested", "Reason", reason) + cancelHandler() + return nil + }); err != nil { + log.Error("Failed to set update handler", "Error", err, "UpdateName", models.TemporalSystemShutdownUpdateName) + return nil, err + } + + // Signal support + heartbeatSignal := workflow.GetSignalChannel(cancelCtx, "heartbeat") + terminateSignal := workflow.GetSignalChannel(cancelCtx, sdkWorkflowsModel.TemporalTerminateSignalName) + + sdkWorkflows.SetupTerminationHandler(rootCtx, terminateSignal, cancelHandler, &terminationRequest) + + selector := workflow.NewSelector(cancelCtx) + selector.AddReceive(heartbeatSignal, func(c workflow.ReceiveChannel, more bool) { + var payload map[string]string + c.Receive(cancelCtx, &payload) + log.Info("Heartbeat signal received") + }) + + log.Info("Starting main system workflow loop") + for { + if err := waitForSystemSignalOrCancel(cancelCtx, selector); err != nil { + return nil, err + } + + if cancelCtx.Err() != nil { + if errors.Is(cancelCtx.Err(), context.Canceled) { + log.Info("Workflow context cancelled, exiting") + break + } + log.Error("Error while waiting", "Error", cancelCtx.Err()) + return nil, cancelCtx.Err() + } + + selector.Select(cancelCtx) + } + + if terminationRequest != nil && len(shutdownReason) == 0 { + shutdownReason = terminationRequest.Reason + } + + return &SystemWorkflowShutdown{ + Identities: identities, + Reason: shutdownReason, + ShutdownAt: workflow.Now(cancelCtx), + }, nil +} + +func setupSystemQueryHandlers(ctx workflow.Context) error { + return workflow.SetQueryHandler(ctx, models.TemporalSystemPingQueryName, func() (string, error) { + log := workflow.GetLogger(ctx) + log.Info("Ping query received") + return "pong", nil + }) +} + +func waitForSystemSignalOrCancel(cancelCtx workflow.Context, selector workflow.Selector) error { + return workflow.Await(cancelCtx, func() bool { + if cancelCtx.Err() != nil { + return true + } + return selector.HasPending() + }) +} + +func dedupeStringsStable(in []string) []string { + if len(in) == 0 { + return nil + } + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if len(v) == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func upsertIdentitiesSearchAttribute(ctx workflow.Context, identities []string) error { + log := workflow.GetLogger(ctx) + if len(identities) == 0 { + log.Warn("upsertIdentitiesSearchAttribute called with empty identities; skipping upsert") + return nil + } + log.Info("Upserting identities typed search attribute", + "Key", sdkConstants.TypedSearchAttributeIdentities.GetName(), + "Identities", identities, + ) + return workflow.UpsertTypedSearchAttributes( + ctx, + sdkConstants.TypedSearchAttributeIdentities.ValueSet(identities), + ) +} diff --git a/internal/daemon/model.go b/internal/daemon/model.go index f4b1e896..66310329 100644 --- a/internal/daemon/model.go +++ b/internal/daemon/model.go @@ -9,6 +9,7 @@ type SimpleServices struct { type SimpleConfig struct { ApiBasePath string + Mode string Server SimpleServer Services SimpleServices } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index d15bb220..bc3a4f15 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -156,6 +156,7 @@ func (s *Server) GetTemplateData(c *gin.Context) TemplateData { return TemplateData{ Config: SimpleConfig{ ApiBasePath: s.Config.GetApiBasePath(), + Mode: string(s.Config.GetMode()), Server: SimpleServer{ Host: s.Config.Server.Host, Port: s.Config.Server.Port, diff --git a/internal/models/provider.go b/internal/models/provider.go index 876cb7d1..6b767ad4 100644 --- a/internal/models/provider.go +++ b/internal/models/provider.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/go-version" "github.com/thand-io/agent/internal/common" "github.com/thand-io/agent/internal/interpolate" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) var ErrNotImplemented = errors.New("not implemented") @@ -111,8 +112,8 @@ type Provider interface { Synchronize(ctx context.Context, temporalClient TemporalImpl, req *SynchronizeRequest) error // Temporal - RegisterWorkflows() any - RegisterActivities() any + RegisterWorkflows(runtime sdkConstants.Mode) any + RegisterActivities(runtime sdkConstants.Mode) any GetCapabilities() *ProviderCapabilities HasCapability(capability ProviderCapability) bool diff --git a/internal/models/provider_activities.go b/internal/models/provider_activities.go index a3e54c25..6217df73 100644 --- a/internal/models/provider_activities.go +++ b/internal/models/provider_activities.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/sirupsen/logrus" + sdkConstants "github.com/thand-io/agent/sdk/constants" "go.temporal.io/sdk/activity" "go.temporal.io/sdk/temporal" ) @@ -16,7 +17,7 @@ import ( // To expose additional, provider-specific activities, override RegisterActivities // on your provider struct to return a populated activities struct (or nil to skip): // -// func (p *myProvider) RegisterActivities() any { +// func (p *myProvider) RegisterActivities(runtime sdkConstants.Mode) any { // return &myProviderActivities{provider: p} // } // @@ -68,7 +69,7 @@ func RegisterProviderActivities(temporalClient TemporalImpl, provider Provider, } // RegisterActivities — BaseProvider default; returns ErrNotImplemented. -func (b *BaseProvider) RegisterActivities() any { +func (b *BaseProvider) RegisterActivities(runtime sdkConstants.Mode) any { return nil } diff --git a/internal/models/provider_capabilities.go b/internal/models/provider_capabilities.go index 2a33187c..0b9fe411 100644 --- a/internal/models/provider_capabilities.go +++ b/internal/models/provider_capabilities.go @@ -4,6 +4,8 @@ import ( "fmt" "slices" "strings" + + sdkConstants "github.com/thand-io/agent/sdk/constants" ) type ProviderCapability string @@ -122,7 +124,8 @@ type WebhookConfiguration = ProviderConfiguration type ProvisioningConfiguration = ProviderConfiguration type ProviderConfiguration struct { - Enabled bool `json:"enabled,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Runtime sdkConstants.Mode `json:"mode,omitempty"` } type ProviderConfigurationImpl interface { @@ -456,6 +459,7 @@ func NewSynchronizableCapability() *SynchronizableConfiguration { func NewCapability() *ProviderConfiguration { return &ProviderConfiguration{ Enabled: true, + Runtime: sdkConstants.ModeServer, } } diff --git a/internal/models/provider_capabilities_test.go b/internal/models/provider_capabilities_test.go index 3b10a446..35a400e7 100644 --- a/internal/models/provider_capabilities_test.go +++ b/internal/models/provider_capabilities_test.go @@ -1,10 +1,12 @@ package models_test import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) func TestBaseProvider_HasCapability(t *testing.T) { @@ -157,6 +159,29 @@ func TestBaseProvider_HasCapability(t *testing.T) { } } +func TestProviderConfiguration_UnmarshalJSON_DefaultsRuntimeToServer(t *testing.T) { + t.Run("omitted mode defaults to server", func(t *testing.T) { + var cfg models.ProviderConfiguration + err := json.Unmarshal([]byte(`{"enabled":true}`), &cfg) + assert.NoError(t, err) + assert.Equal(t, sdkConstants.ModeServer, cfg.Runtime) + }) + + t.Run("empty mode defaults to server", func(t *testing.T) { + var cfg models.ProviderConfiguration + err := json.Unmarshal([]byte(`{"enabled":true,"mode":""}`), &cfg) + assert.NoError(t, err) + assert.Equal(t, sdkConstants.ModeServer, cfg.Runtime) + }) + + t.Run("explicit mode is preserved", func(t *testing.T) { + var cfg models.ProviderConfiguration + err := json.Unmarshal([]byte(`{"enabled":true,"mode":"agent"}`), &cfg) + assert.NoError(t, err) + assert.Equal(t, sdkConstants.ModeAgent, cfg.Runtime) + }) +} + // Helper to create fully initialized capabilities for testing Enable/Disable func newInitializedCapabilities() *models.ProviderCapabilities { return &models.ProviderCapabilities{ diff --git a/internal/models/provider_notifier.go b/internal/models/provider_notifier.go index feac75f9..1421b0e8 100644 --- a/internal/models/provider_notifier.go +++ b/internal/models/provider_notifier.go @@ -9,13 +9,36 @@ type NotificationRequest map[string]any type ProviderNotifier interface { - // Allow this provider to send notifications - SendNotification(ctx context.Context, notification NotificationRequest) error + // Allow this provider to send notifications. + // + // Notifier providers receive a ProviderContext (rather than a plain + // context.Context) so that, when invoked from within a Temporal workflow, + // they can dispatch the underlying API call as a Temporal activity for + // retry/replay determinism. Providers that do not support workflow + // dispatch should type-assert the context to context.Context and proceed + // directly. See email/slack providers for the dual-mode pattern. + SendNotification(ctx ProviderContext, notification NotificationRequest) error } /* Default implementations for notifiers */ -func (p *BaseProvider) SendNotification(ctx context.Context, notification NotificationRequest) error { +func (p *BaseProvider) SendNotification(ctx ProviderContext, notification NotificationRequest) error { // Default implementation does nothing return fmt.Errorf("the provider '%s' does not implement SendNotification", p.GetProvider()) } + +// ContextFromProviderContext extracts a context.Context from a ProviderContext. +// When ctx is already a context.Context it is returned unchanged; when ctx is a +// workflow.Context (or any other ProviderContext implementation) the helper +// falls back to context.Background() so callers get a usable context for +// outbound API calls. This mirrors the type-assertion pattern used by the AWS +// provider's exec* helpers. +func ContextFromProviderContext(ctx ProviderContext) context.Context { + if ctx == nil { + return context.Background() + } + if goCtx, ok := ctx.(context.Context); ok { + return goCtx + } + return context.Background() +} diff --git a/internal/models/provider_sync.go b/internal/models/provider_sync.go index 8c062d03..f11e284a 100644 --- a/internal/models/provider_sync.go +++ b/internal/models/provider_sync.go @@ -243,7 +243,6 @@ func Synchronize( // (runSyncLoop) and the pure-Go path (executeSync) delegate to this function. func paginatedSync[Req SynchronizeRequestImpl, Resp SynchronizeResponseImpl]( provider Provider, - name SynchronizeCapability, req Req, executePage func(Req) (Resp, error), onPage func(Resp), @@ -294,7 +293,7 @@ func executeSync[Req SynchronizeRequestImpl, Resp SynchronizeResponseImpl]( wg.Go(func() { logrus.Infof("Starting synchronization operation: %s", name) - err := paginatedSync(provider, name, req, + err := paginatedSync(provider, req, func(r Req) (Resp, error) { return syncOp(ctx, r) }, nil, // no post-page hook in the pure-Go path - TODO add patching support ) diff --git a/internal/models/provider_temporal.go b/internal/models/provider_temporal.go index 34efc9a3..ac9e84d5 100644 --- a/internal/models/provider_temporal.go +++ b/internal/models/provider_temporal.go @@ -12,11 +12,23 @@ import ( ) const TemporalSynchronizeWorkflowName = "synchronize" + const TemporalAuthorizeRoleWorkflowName = "authorize-role" const TemporalRevokeRoleWorkflowName = "revoke-role" + +const TemporalNotifyWorkflowName = "notify" + +const TemporalLookupSystemIdentifierActivityName = "lookup-system-identifier" const TemporalPatchProviderUpstreamActivityName = "patch-provider-upstream" const TemporalBuildAuthorizeRoleRequestActivityName = "build-authorize-role-request" +// SendNotificationActivityName is the unqualified Temporal activity name that +// notifier providers (email, slack, local.notification, ...) expose via their +// RegisterActivities(runtime sdkConstants.Mode) struct so the shared notify workflow can dispatch +// SendNotification calls as a Temporal activity. The fully qualified name is +// produced via CreateTemporalProviderWorkflowName(, SendNotificationActivityName). +const SendNotificationActivityName = "SendNotificationActivity" + func CreateTemporalProviderWorkflowIdentifier(identifier, base string) string { return CreateTemporalWorkflowIdentifier(fmt.Sprintf("%s-%s", identifier, base)) } @@ -77,7 +89,7 @@ func RegisterActivities(temporalClient TemporalImpl, identifier string, s any) e }, ) - logrus.Debugf("Registered activity: %s for provider: %s", activityName, identifier) + logrus.Infof("Registered activity: %s for provider: %s", activityName, identifier) count++ } diff --git a/internal/models/provider_workflows.go b/internal/models/provider_workflows.go index b1c0fdfd..f6e51144 100644 --- a/internal/models/provider_workflows.go +++ b/internal/models/provider_workflows.go @@ -11,6 +11,10 @@ import ( "time" "github.com/thand-io/agent/internal/common" + sdkConstants "github.com/thand-io/agent/sdk/constants" + + // thandFunction "github.com/thand-io/agent/internal/workflows/functions/providers/thand" + "go.temporal.io/sdk/temporal" "go.temporal.io/sdk/workflow" ) @@ -38,7 +42,7 @@ type SynchronizeResponse struct { } // BaseProvider provides a base implementation of the ProviderImpl interface -func (b *BaseProvider) RegisterWorkflows() any { +func (b *BaseProvider) RegisterWorkflows(runtime sdkConstants.Mode) any { return nil } @@ -46,17 +50,13 @@ func CreateTemporalWorkflowIdentifier(workflowName string) string { return strings.ToLower(fmt.Sprintf("%s-%s", common.GetClientIdentifier(), workflowName)) } -// CreateChildWorkflowID generates a unique child workflow ID by hashing a composite +// CreateChildWorkflowIDFromRole generates a unique child workflow ID by hashing a composite // identifier built from provider, role, identity, tenant, and parent workflow ID. // This ensures uniqueness across different identities/tenants requesting the same role. // Format: parentWorkflowID_operation_hash -func CreateChildWorkflowID(parentWorkflowID, operation, provider string, req *WorkflowRoleRequest) string { - // Build composite identifier similar to CompositeRoleWorkflowIdentifier - // but using the data available in WorkflowRoleRequest - parts := []string{ - parentWorkflowID, - provider, - } +func CreateChildWorkflowIDFromRole(parentWorkflowID, operation, provider string, req *WorkflowRoleRequest) string { + + parts := []string{} if req.Role != nil { parts = append(parts, req.Role.Identifier) @@ -70,6 +70,24 @@ func CreateChildWorkflowID(parentWorkflowID, operation, provider string, req *Wo parts = append(parts, req.Tenant) } + return CreateChildWorkflowID( + parentWorkflowID, + operation, + provider, + parts..., + ) +} + +func CreateChildWorkflowID(parentWorkflowID, operation, provider string, newParts ...string) string { + // Build composite identifier similar to CompositeRoleWorkflowIdentifier + // but using the data available in WorkflowRoleRequest + parts := []string{ + parentWorkflowID, + provider, + } + + parts = append(parts, newParts...) + // Create composite string composite := strings.Join(parts, ":") @@ -115,7 +133,7 @@ func runSyncLoop[Req SynchronizeRequestImpl, Resp SynchronizeResponseImpl]( // even when thousands of pages are produced. var patchPayloads []Resp - err := paginatedSync(provider, activityMethod, req, + err := paginatedSync(provider, req, // executePage: run the local activity and return the deserialized response. func(r Req) (Resp, error) { var resp Resp @@ -356,6 +374,14 @@ func CreateProviderAuthorizeRoleWorkflow(provider Provider) func(workflow.Contex "authorizeReq", authReq, ) + ctx = evaluateRuntime( + ctx, + provider.GetCapabilities().Provisioning.Runtime, + common.GetClientIdentifier().String(), + ) + + // We can just execute this without an activity - as this is a handler + // that has several activities inside for all the operations return provider.AuthorizeRole(ctx, &authReq) } } @@ -410,6 +436,14 @@ func CreateProviderRevokeRoleWorkflow(provider Provider) func(workflow.Context, "revokeReq", revokeReq, ) + ctx = evaluateRuntime( + ctx, + provider.GetCapabilities().Provisioning.Runtime, + common.GetClientIdentifier().String(), + ) + + // We can just execute this without an activity - as this is a handler + // that has several activities inside for all the operations return provider.RevokeRole(ctx, revokeReq) } } @@ -473,6 +507,49 @@ func CreateAuthorizeRoleRequest( }, nil } +type WorkflowNotifyRequest struct { + WorkflowID string `json:"workflow_id"` // ID of the workflow for which the role is being authorized + Recipient string `json:"recipient"` + Payload NotificationRequest `json:"request"` +} + +type WorkflowNotifyResponse struct { +} + +// CreateProviderNotifyWorkflow +// the notify workflow is being executed outside of the request auth/revoke auth +// so it may not have access to the local providers. +// +// The workflow forwards the workflow.Context (which satisfies ProviderContext) +// to provider.SendNotification. Providers that wrap the underlying API call +// as a Temporal activity (email, slack, local.notification) detect the +// workflow context and dispatch via ExecuteActivity for retry/replay +// determinism. This mirrors CreateProviderAuthorizeRoleWorkflow. +func CreateProviderNotifyWorkflow(provider Provider) func(workflow.Context, WorkflowNotifyRequest) (*WorkflowNotifyResponse, error) { + return func(ctx workflow.Context, req WorkflowNotifyRequest) (*WorkflowNotifyResponse, error) { + + log := workflow.GetLogger(ctx) + log.Info("Starting notify workflow", "provider", provider.GetIdentifier()) + + if len(req.Recipient) == 0 { + return nil, fmt.Errorf("recipient is required for notification") + } + + ctx = evaluateRuntime( + ctx, + provider.GetCapabilities().Notifier.Runtime, + req.Recipient, // Lookup based on identifier hugh@thand.io or hostname + ) + + if err := provider.SendNotification(ctx, req.Payload); err != nil { + log.Error("failed to send notification", "error", err) + return nil, err + } + + return &WorkflowNotifyResponse{}, nil + } +} + // validateRoleAndBuildOutput validates the role and builds the initial model output // Careful: This function is called within a workflow.SideEffect, so it must be deterministic and cannot perform any Temporal operations (activities, child workflows, timers) or access non-deterministic data (current time, random numbers). It can only use the data passed in the parameters and perform pure computations. func validateRoleAndBuildOutput( @@ -493,3 +570,46 @@ func validateRoleAndBuildOutput( return modelOutput, nil } + +func evaluateRuntime( + ctx workflow.Context, + runtime sdkConstants.Mode, + identifier string, +) workflow.Context { + + log := workflow.GetLogger(ctx) + + if runtime == sdkConstants.ModeServer { + log.Info("Evaluating server runtime for provider workflow", "identifier", identifier) + return ctx + } + + log.Info("Running system id activity") + + lao := workflow.ActivityOptions{ + StartToCloseTimeout: 30 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + InitialInterval: 1 * time.Second, + BackoffCoefficient: 2.0, + MaximumInterval: 30 * time.Second, + MaximumAttempts: 5, + }, + } + + lctx := workflow.WithActivityOptions(ctx, lao) + + var result string + if err := workflow.ExecuteActivity( + lctx, + TemporalLookupSystemIdentifierActivityName, + identifier, + ).Get(ctx, &result); err != nil { + log.Error("Failed to build authorize role request for revocation", "error", err) + return ctx + } + + log.Info("found system id", "identifiter", result) + + return workflow.WithTaskQueue(ctx, result) + +} diff --git a/internal/models/provider_workflows_childid_test.go b/internal/models/provider_workflows_childid_test.go index 1f43a613..3e8c4228 100644 --- a/internal/models/provider_workflows_childid_test.go +++ b/internal/models/provider_workflows_childid_test.go @@ -56,10 +56,10 @@ func TestCreateChildWorkflowID_Uniqueness(t *testing.T) { provider := "aws" // Generate child workflow IDs - wfID1 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req1) - wfID2 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req2) - wfID3 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req3) - wfID4 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req4) + wfID1 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req1) + wfID2 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req2) + wfID3 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req3) + wfID4 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req4) // Verify all IDs are different assert.NotEqual(t, wfID1, wfID2, "Different identities should produce different workflow IDs") @@ -107,9 +107,9 @@ func TestCreateChildWorkflowID_Deterministic(t *testing.T) { provider := "gcp" // Generate the same ID multiple times - wfID1 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req) - wfID2 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req) - wfID3 := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req) + wfID1 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req) + wfID2 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req) + wfID3 := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req) // Verify all IDs are identical assert.Equal(t, wfID1, wfID2, "Same input should produce identical workflow IDs") @@ -136,9 +136,9 @@ func TestCreateChildWorkflowID_DifferentProviders(t *testing.T) { } // Generate IDs for different providers - wfIDAWS := CreateChildWorkflowID(parentWfID, "authorizeRole", "aws", req) - wfIDGCP := CreateChildWorkflowID(parentWfID, "authorizeRole", "gcp", req) - wfIDAzure := CreateChildWorkflowID(parentWfID, "authorizeRole", "azure", req) + wfIDAWS := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", "aws", req) + wfIDGCP := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", "gcp", req) + wfIDAzure := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", "azure", req) // Verify all IDs are different assert.NotEqual(t, wfIDAWS, wfIDGCP, "Different providers should produce different workflow IDs") @@ -171,8 +171,8 @@ func TestCreateChildWorkflowID_DifferentOperations(t *testing.T) { provider := "aws" // Generate IDs for different operations - authID := CreateChildWorkflowID(parentWfID, "authorizeRole", provider, req) - revokeID := CreateChildWorkflowID(parentWfID, "revokeRole", provider, req) + authID := CreateChildWorkflowIDFromRole(parentWfID, "authorizeRole", provider, req) + revokeID := CreateChildWorkflowIDFromRole(parentWfID, "revokeRole", provider, req) // Verify IDs are different (different operations) assert.NotEqual(t, authID, revokeID, "Different operations should produce different workflow IDs") diff --git a/internal/models/temporal.go b/internal/models/temporal.go index cbc74250..8ffe40a5 100644 --- a/internal/models/temporal.go +++ b/internal/models/temporal.go @@ -12,6 +12,11 @@ const TemporalExecuteElevationWorkflowName = "ExecuteElevationWorkflow" const TemporalIsApprovedQueryName = "isApproved" const TemporalGetWorkflowTaskQueryName = "getWorkflowTask" +// System workflow (internal/config) query + update names +const TemporalSystemPingQueryName = "ping" +const TemporalSystemUpdateIdentitiesUpdateName = "updateIdentities" +const TemporalSystemShutdownUpdateName = "shutdown" + // TemporalAuthAPIKey represents API Key authentication configuration type TemporalAuthAPIKey struct { ApiKey string `mapstructure:"api_key" default:""` @@ -67,9 +72,8 @@ type TemporalImpl interface { GetClient() client.Client HasClient() bool - // GetWorker returns a synthetic worker that broadcasts registration calls - // across all (or a filtered subset of) identity-specific workers. - GetWorker(identities ...string) worker.Worker + // GetWorker returns the underlying Temporal worker, or nil if not initialized. + GetWorker() worker.Worker HasWorker() bool GetHostPort() string diff --git a/internal/providers/aws/activities.go b/internal/providers/aws/activities.go index fd8c45e4..d9e59cff 100644 --- a/internal/providers/aws/activities.go +++ b/internal/providers/aws/activities.go @@ -5,9 +5,10 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) -func (b *awsProvider) RegisterActivities() any { +func (b *awsProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &awsProviderActivities{provider: b} } diff --git a/internal/providers/azure/activities.go b/internal/providers/azure/activities.go index faa6a9a6..c4fbefc3 100644 --- a/internal/providers/azure/activities.go +++ b/internal/providers/azure/activities.go @@ -5,9 +5,10 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) -func (b *azureProvider) RegisterActivities() any { +func (b *azureProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &azureProviderActivities{provider: b} } diff --git a/internal/providers/cloudflare/activities.go b/internal/providers/cloudflare/activities.go index cd980f02..5b6c4dc9 100644 --- a/internal/providers/cloudflare/activities.go +++ b/internal/providers/cloudflare/activities.go @@ -1,5 +1,9 @@ package cloudflare -func (b *cloudflareProvider) RegisterActivities() any { +import ( + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +func (b *cloudflareProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &cloudflareProviderActivities{provider: b} } diff --git a/internal/providers/email.acs/main.go b/internal/providers/email.acs/main.go index dff0e80b..e3a498ff 100644 --- a/internal/providers/email.acs/main.go +++ b/internal/providers/email.acs/main.go @@ -2,7 +2,6 @@ package email_acs import ( "bytes" - "context" "encoding/json" "fmt" "io" @@ -65,9 +64,11 @@ func (p *emailAcsProvider) Initialize(identifier string, provider models.Provide } func (p *emailAcsProvider) SendNotification( - ctx context.Context, notification models.NotificationRequest, + ctx models.ProviderContext, notification models.NotificationRequest, ) error { + goCtx := models.ContextFromProviderContext(ctx) + // Convert NotificationRequest to EmailNotificationRequest emailRequest := &models.EmailNotificationRequest{} common.ConvertMapToInterface(notification, emailRequest) @@ -116,7 +117,7 @@ func (p *emailAcsProvider) SendNotification( } // Get access token - token, err := p.credential.Token.GetToken(ctx, policy.TokenRequestOptions{ + token, err := p.credential.Token.GetToken(goCtx, policy.TokenRequestOptions{ Scopes: []string{"https://communication.azure.com/.default"}, }) if err != nil { @@ -125,7 +126,7 @@ func (p *emailAcsProvider) SendNotification( // Send email using Azure Communication Services REST API url := fmt.Sprintf("%s/emails:send?api-version=2023-03-31", p.endpoint) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(requestBody)) + req, err := http.NewRequestWithContext(goCtx, "POST", url, bytes.NewBuffer(requestBody)) if err != nil { return fmt.Errorf("failed to create HTTP request: %w", err) } diff --git a/internal/providers/email.ses/main.go b/internal/providers/email.ses/main.go index b0c1333b..3160585f 100644 --- a/internal/providers/email.ses/main.go +++ b/internal/providers/email.ses/main.go @@ -1,7 +1,6 @@ package email_ses import ( - "context" "fmt" "github.com/aws/aws-sdk-go-v2/aws" @@ -58,9 +57,11 @@ func (p *emailSesProvider) Initialize(identifier string, provider models.Provide } func (p *emailSesProvider) SendNotification( - ctx context.Context, notification models.NotificationRequest, + ctx models.ProviderContext, notification models.NotificationRequest, ) error { + goCtx := models.ContextFromProviderContext(ctx) + // Convert NotificationRequest to EmailNotificationRequest emailRequest := &models.EmailNotificationRequest{} common.ConvertMapToInterface(notification, emailRequest) @@ -117,7 +118,7 @@ func (p *emailSesProvider) SendNotification( Content: emailContent, } - _, err := p.sesClient.SendEmail(ctx, input) + _, err := p.sesClient.SendEmail(goCtx, input) if err != nil { return fmt.Errorf("failed to send email via SES: %w", err) } diff --git a/internal/providers/email.smtp/main.go b/internal/providers/email.smtp/main.go index d7991ad2..c8ea8370 100644 --- a/internal/providers/email.smtp/main.go +++ b/internal/providers/email.smtp/main.go @@ -1,7 +1,6 @@ package email_smtp import ( - "context" "fmt" "crypto/tls" @@ -79,9 +78,15 @@ func (p *emailSmtpProvider) Initialize(identifier string, provider models.Provid } func (p *emailSmtpProvider) SendNotification( - ctx context.Context, notification models.NotificationRequest, + ctx models.ProviderContext, notification models.NotificationRequest, ) error { + // gomail's dialer does not accept a context; the ProviderContext is only + // used to satisfy the ProviderNotifier interface and may also carry a + // workflow.Context when invoked from within a Temporal workflow (the + // outer email provider performs the activity dispatch). + _ = ctx + // Lets convert NotificationRequest to EmailNotificationRequest emailRequest := &models.EmailNotificationRequest{} common.ConvertMapToInterface(notification, emailRequest) diff --git a/internal/providers/email/activities.go b/internal/providers/email/activities.go new file mode 100644 index 00000000..7c63f80a --- /dev/null +++ b/internal/providers/email/activities.go @@ -0,0 +1,30 @@ +package email + +import ( + "context" + + "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +// emailProviderActivities exposes the email provider's outbound API call as a +// Temporal activity. Registered via RegisterActivities(runtime sdkConstants.Mode) so the shared notify +// workflow can dispatch SendNotification with retry/replay determinism. +type emailProviderActivities struct { + provider *emailProvider +} + +func (p *emailProvider) RegisterActivities(runtime sdkConstants.Mode) any { + return &emailProviderActivities{provider: p} +} + +// SendNotificationActivity is the Temporal activity wrapper around +// emailProvider.sendNotificationDirect. The exported method name must match +// models.SendNotificationActivityName so reflection-based registration in +// models.RegisterActivities produces the expected activity name. +func (a *emailProviderActivities) SendNotificationActivity( + ctx context.Context, + notification models.NotificationRequest, +) error { + return a.provider.sendNotificationDirect(ctx, notification) +} diff --git a/internal/providers/email/main.go b/internal/providers/email/main.go index 76fe6438..36c2a16b 100644 --- a/internal/providers/email/main.go +++ b/internal/providers/email/main.go @@ -3,12 +3,15 @@ package email import ( "context" "fmt" + "time" "github.com/thand-io/agent/internal/models" "github.com/thand-io/agent/internal/providers" emailacs "github.com/thand-io/agent/internal/providers/email.acs" ses "github.com/thand-io/agent/internal/providers/email.ses" smtp "github.com/thand-io/agent/internal/providers/email.smtp" + sdkWorkflowsRunner "github.com/thand-io/agent/sdk/workflows/runner" + "go.temporal.io/sdk/workflow" ) const EmailProviderName = "email" @@ -54,13 +57,43 @@ func (p *emailProvider) Initialize(identifier string, provider models.ProviderCo } func (p *emailProvider) SendNotification( - ctx context.Context, notification models.NotificationRequest, + ctx models.ProviderContext, notification models.NotificationRequest, ) error { if p.proxy == nil { return fmt.Errorf("email provider proxy is not initialized") } + // When invoked from a Temporal workflow coroutine, dispatch the actual + // email API call as a Temporal activity so it benefits from retry, + // history, and replay determinism. Mirrors the AWS provider's exec* + // helpers. + if workflowCtx, ok := ctx.(workflow.Context); ok { + wfCtx := workflow.WithActivityOptions(workflowCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: sdkWorkflowsRunner.DefaultRetryPolicy, + }) + return workflow.ExecuteActivity( + wfCtx, + models.CreateTemporalProviderWorkflowName(p.GetIdentifier(), models.SendNotificationActivityName), + notification, + ).Get(wfCtx, nil) + } + + return p.sendNotificationDirect(models.ContextFromProviderContext(ctx), notification) +} + +// sendNotificationDirect performs the underlying API call against the +// configured email proxy (smtp / ses / acs / mock). It is invoked both +// directly (when no Temporal workflow context is present) and as the body of +// the SendNotificationActivity Temporal activity. +func (p *emailProvider) sendNotificationDirect( + ctx context.Context, + notification models.NotificationRequest, +) error { + if p.proxy == nil { + return fmt.Errorf("email provider proxy is not initialized") + } return p.proxy.SendNotification(ctx, notification) } diff --git a/internal/providers/email/mock.go b/internal/providers/email/mock.go index 317973a0..6beb7d85 100644 --- a/internal/providers/email/mock.go +++ b/internal/providers/email/mock.go @@ -187,7 +187,7 @@ func (p *emailProviderMock) Initialize(identifier string, provider models.Provid // SendNotification captures the email instead of sending it func (p *emailProviderMock) SendNotification( - ctx context.Context, notification models.NotificationRequest, + ctx models.ProviderContext, notification models.NotificationRequest, ) error { // Convert NotificationRequest to EmailNotificationRequest emailRequest := &models.EmailNotificationRequest{} diff --git a/internal/providers/gcp/activities.go b/internal/providers/gcp/activities.go index 35f196b0..05f9eba2 100644 --- a/internal/providers/gcp/activities.go +++ b/internal/providers/gcp/activities.go @@ -5,9 +5,10 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) -func (b *gcpProvider) RegisterActivities() any { +func (b *gcpProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &gcpProviderActivities{provider: b} } diff --git a/internal/providers/github/activities.go b/internal/providers/github/activities.go index eb8d5755..d291e66e 100644 --- a/internal/providers/github/activities.go +++ b/internal/providers/github/activities.go @@ -5,9 +5,10 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) -func (b *githubProvider) RegisterActivities() any { +func (b *githubProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &githubProviderActivities{provider: b} } diff --git a/internal/providers/kubernetes/activities.go b/internal/providers/kubernetes/activities.go index 16ff7232..f885485c 100644 --- a/internal/providers/kubernetes/activities.go +++ b/internal/providers/kubernetes/activities.go @@ -1,5 +1,9 @@ package kubernetes -func (b *kubernetesProvider) RegisterActivities() any { +import ( + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +func (b *kubernetesProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &kubernetesProviderActivities{provider: b} } diff --git a/internal/providers/okta/activities.go b/internal/providers/okta/activities.go index 176fc2e4..a60a34ca 100644 --- a/internal/providers/okta/activities.go +++ b/internal/providers/okta/activities.go @@ -5,9 +5,10 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) -func (b *oktaProvider) RegisterActivities() any { +func (b *oktaProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &oktaProviderActivities{provider: b} } diff --git a/internal/providers/proxy.go b/internal/providers/proxy.go index 5365c108..b5fd9d9f 100644 --- a/internal/providers/proxy.go +++ b/internal/providers/proxy.go @@ -10,6 +10,7 @@ import ( "github.com/sirupsen/logrus" "github.com/thand-io/agent/internal/common" "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" ) const ProviderProxySessionKey = "session" @@ -30,6 +31,17 @@ func NewRemoteProviderProxy(providerKey, endpoint string) models.Provider { } } +// TODO fix this. - we need to register workflows/activities for any provider that is proxied in order to be able to call them from temporal workflows. We can have a generic set of workflows/activities that can be used for any proxied provider, and then we can have the provider return a list of custom workflows/activities that it wants to register as well. The activities would just be responsible for proxying the request to the provider's API and returning the response. +// andy proxy requests + +func (p *remoteProviderProxy) RegisterWorkflows(runtime sdkConstants.Mode) any { + return nil +} + +func (p *remoteProviderProxy) RegisterActivities(runtime sdkConstants.Mode) any { + return nil +} + func (p *remoteProviderProxy) Initialize(identifier string, provider models.ProviderConfig) error { p.BaseProvider = models.NewBaseProvider( diff --git a/internal/providers/salesforce/activities.go b/internal/providers/salesforce/activities.go index 43e4006a..f9fe9d7b 100644 --- a/internal/providers/salesforce/activities.go +++ b/internal/providers/salesforce/activities.go @@ -1,5 +1,9 @@ package salesforce -func (b *salesForceProvider) RegisterActivities() any { +import ( + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +func (b *salesForceProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &salesForceProviderActivities{provider: b} } diff --git a/internal/providers/slack/activities.go b/internal/providers/slack/activities.go new file mode 100644 index 00000000..35a9d7d6 --- /dev/null +++ b/internal/providers/slack/activities.go @@ -0,0 +1,31 @@ +package slack + +import ( + "context" + + "github.com/thand-io/agent/internal/models" + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +// slackProviderActivities exposes the Slack provider's outbound API calls as +// Temporal activities. Registered via RegisterActivities(runtime sdkConstants.Mode) so the shared +// notify workflow (and any provider workflow that dispatches via the +// SendNotificationActivityName helper) can retry/replay them safely. +type slackProviderActivities struct { + provider *slackProvider +} + +func (p *slackProvider) RegisterActivities(runtime sdkConstants.Mode) any { + return &slackProviderActivities{provider: p} +} + +// SendNotificationActivity is the Temporal activity wrapper around +// slackProvider.sendNotificationDirect. The exported method name must match +// models.SendNotificationActivityName so reflection-based registration in +// models.RegisterActivities produces the expected activity name. +func (a *slackProviderActivities) SendNotificationActivity( + ctx context.Context, + notification models.NotificationRequest, +) error { + return a.provider.sendNotificationDirect(ctx, notification) +} diff --git a/internal/providers/slack/main.go b/internal/providers/slack/main.go index 9493f5df..e76748d5 100644 --- a/internal/providers/slack/main.go +++ b/internal/providers/slack/main.go @@ -10,7 +10,9 @@ import ( "github.com/thand-io/agent/internal/common" "github.com/thand-io/agent/internal/models" "github.com/thand-io/agent/internal/providers" + sdkWorkflowsRunner "github.com/thand-io/agent/sdk/workflows/runner" "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" ) const SlackProviderName = "slack" @@ -55,7 +57,27 @@ type SlackNotificationRequest struct { Attachments []slack.Attachment `json:"attachments,omitempty"` } -func (p *slackProvider) SendNotification(ctx context.Context, notification models.NotificationRequest) error { +func (p *slackProvider) SendNotification(ctx models.ProviderContext, notification models.NotificationRequest) error { + // When invoked from a Temporal workflow coroutine, dispatch the actual + // Slack API call as a Temporal activity so it benefits from retry, + // history, and replay determinism. Mirrors the AWS provider's exec* + // helpers. + if workflowCtx, ok := ctx.(workflow.Context); ok { + wfCtx := workflow.WithActivityOptions(workflowCtx, workflow.ActivityOptions{ + StartToCloseTimeout: 2 * time.Minute, + RetryPolicy: sdkWorkflowsRunner.DefaultRetryPolicy, + }) + return workflow.ExecuteActivity( + wfCtx, + models.CreateTemporalProviderWorkflowName(p.GetIdentifier(), models.SendNotificationActivityName), + notification, + ).Get(wfCtx, nil) + } + + return p.sendNotificationDirect(models.ContextFromProviderContext(ctx), notification) +} + +func (p *slackProvider) sendNotificationDirect(ctx context.Context, notification models.NotificationRequest) error { // Convert NotificationRequest to SlackNotificationRequest slackRequest := &SlackNotificationRequest{} common.ConvertMapToInterface(notification, slackRequest) diff --git a/internal/providers/terraform/activities.go b/internal/providers/terraform/activities.go index df41b8e2..3d2c7eda 100644 --- a/internal/providers/terraform/activities.go +++ b/internal/providers/terraform/activities.go @@ -1,5 +1,9 @@ package terraform -func (b *terraformProvider) RegisterActivities() any { +import ( + sdkConstants "github.com/thand-io/agent/sdk/constants" +) + +func (b *terraformProvider) RegisterActivities(runtime sdkConstants.Mode) any { return &terraformProviderActivities{provider: b} } diff --git a/internal/workflows/tasks/providers/thand/approval_callback.go b/internal/workflows/tasks/providers/thand/approval_callback.go new file mode 100644 index 00000000..c46dee11 --- /dev/null +++ b/internal/workflows/tasks/providers/thand/approval_callback.go @@ -0,0 +1,8 @@ +package thand + +// approvalEventSource is the cloudevent source string used for approval events +// emitted by the agent. It matches the value used by createCallbackUrl in +// approvals_slack.go / approvals_email.go so that listener task filters and +// downstream consumers see a consistent source regardless of which channel +// (slack, email, local presence) produced the signal. +const approvalEventSource = "urn:thand:agent" diff --git a/internal/workflows/tasks/providers/thand/approvals.go b/internal/workflows/tasks/providers/thand/approvals.go index 72821db7..96beb52c 100644 --- a/internal/workflows/tasks/providers/thand/approvals.go +++ b/internal/workflows/tasks/providers/thand/approvals.go @@ -21,9 +21,13 @@ import ( var ThandApprovalsTask = "approvals" type ApprovalsTask struct { - Approvals int `json:"approvals" default:"1"` - SelfApprove bool `json:"selfApprove" default:"false"` - Notifiers map[string]thandFunction.NotifierRequest `json:"notifiers"` + Approvals int `json:"approvals" default:"1"` + SelfApprove bool `json:"selfApprove" default:"false"` + // DisableUI hides the Approve/Reject controls on the workflow execution + // page in the UI. Approvals must instead be made through configured + // notifiers (e.g. Slack, email, local device presence). Defaults to false. + DisableUI bool `json:"disableUI" default:"false"` + Notifiers map[string]thandFunction.NotifierRequest `json:"notifiers"` } func (n *ApprovalsTask) IsValid() bool { @@ -478,10 +482,10 @@ func (t *thandTask) makeApprovalNotifications( recipientPayload := approvalNotifier.GetPayload(recipientIdentity) notifyTasks = append(notifyTasks, notifyTask{ - Recipient: recipientID, - CallFunc: approvalNotifier.GetCallFunction(recipientIdentity), - Payload: recipientPayload, - Provider: approvalNotifier.GetProviderName(), + Recipient: recipientID, + CallFunc: approvalNotifier.GetCallFunction(recipientIdentity), + Payload: recipientPayload, + ProviderName: approvalNotifier.GetProviderName(), }) logrus.WithFields(logrus.Fields{ diff --git a/internal/workflows/tasks/providers/thand/authorize.go b/internal/workflows/tasks/providers/thand/authorize.go index 14d996aa..a46d6736 100644 --- a/internal/workflows/tasks/providers/thand/authorize.go +++ b/internal/workflows/tasks/providers/thand/authorize.go @@ -314,9 +314,9 @@ func (t *thandTask) runAuthTask( // (provider + role + identity + tenant) to ensure uniqueness across // different identities/tenants requesting the same role childOpts := workflow.ChildWorkflowOptions{ - WorkflowID: models.CreateChildWorkflowID( + WorkflowID: models.CreateChildWorkflowIDFromRole( workflowTask.GetWorkflowID(), - "authorizeRole", + models.TemporalAuthorizeRoleWorkflowName, // This can be anything task.ProviderName, task.AuthRequest, ), @@ -595,10 +595,10 @@ func (t *thandTask) makeAuthorizationNotifications( recipientPayload := authorizeNotifier.GetPayload(recipientIdentity) notifyTasks = append(notifyTasks, notifyTask{ - Recipient: recipientId, - CallFunc: authorizeNotifier.GetCallFunction(recipientIdentity), - Payload: recipientPayload, - Provider: authorizeNotifier.GetProviderName(), + Recipient: recipientId, + CallFunc: authorizeNotifier.GetCallFunction(recipientIdentity), + Payload: recipientPayload, + ProviderName: authorizeNotifier.GetProviderName(), }) log.WithFields(logrus.Fields{ diff --git a/internal/workflows/tasks/providers/thand/form.go b/internal/workflows/tasks/providers/thand/form.go index d6b4d8ab..a3715b46 100644 --- a/internal/workflows/tasks/providers/thand/form.go +++ b/internal/workflows/tasks/providers/thand/form.go @@ -284,10 +284,10 @@ func (t *thandTask) makeFormNotifications( recipientPayload := formNotifier.GetPayload(recipientIdentity) notifyTasks = append(notifyTasks, notifyTask{ - Recipient: recipientId, - CallFunc: formNotifier.GetCallFunction(recipientIdentity), - Payload: recipientPayload, - Provider: formNotifier.GetProviderName(), + Recipient: recipientId, + CallFunc: formNotifier.GetCallFunction(recipientIdentity), + Payload: recipientPayload, + ProviderName: formNotifier.GetProviderName(), }) logrus.WithFields(logrus.Fields{ diff --git a/internal/workflows/tasks/providers/thand/notify.go b/internal/workflows/tasks/providers/thand/notify.go index 630d0360..e730702c 100644 --- a/internal/workflows/tasks/providers/thand/notify.go +++ b/internal/workflows/tasks/providers/thand/notify.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" "sync" - "time" "github.com/serverlessworkflow/sdk-go/v3/model" "github.com/sirupsen/logrus" @@ -13,7 +12,7 @@ import ( "github.com/thand-io/agent/internal/models" thandFunction "github.com/thand-io/agent/internal/workflows/functions/providers/thand" taskModel "github.com/thand-io/agent/internal/workflows/tasks/model" - sdkWorkflowsRunner "github.com/thand-io/agent/sdk/workflows/runner" + sdkWorkflowsModel "github.com/thand-io/agent/sdk/workflows/models" "go.temporal.io/sdk/workflow" ) @@ -29,10 +28,10 @@ type notifyResult struct { // notifyTask represents a notification task with all necessary context type notifyTask struct { - Recipient string - CallFunc model.CallFunction - Payload models.NotificationRequest - Provider string + ProviderName string + Recipient string + CallFunc model.CallFunction + Payload models.NotificationRequest } // temporalNotifyResult represents the result of a notification operation for temporal communication @@ -65,23 +64,6 @@ func (t *thandTask) executeNotifyTask( return nil, errors.New("invalid notification request") } - notifierProviders := t.config.GetProvidersByCapability( - models.ProviderCapabilityNotifier) - - if !hasMatchingProvider(notifyReq, notifierProviders) { - return nil, fmt.Errorf("no matching provider found for name: %s", notifyReq.Provider) - } - - elevationReq, err := workflowTask.GetContextAsElevationRequest() - - if err != nil { - return nil, fmt.Errorf("failed to get elevation request from input: %w", err) - } - - if !elevationReq.IsValid() { - return nil, errors.New("elevation request is not valid") - } - notifyImpl := NewDefaultNotifierImpl(notifyReq) return t.executeNotify(workflowTask, taskName, notifyImpl) @@ -124,10 +106,10 @@ func (t *thandTask) executeNotify( recipientPayload := notify.GetPayload(recipientIdentity) notifyTasks = append(notifyTasks, notifyTask{ - Recipient: recipientId, - CallFunc: notify.GetCallFunction(recipientIdentity), - Payload: recipientPayload, - Provider: notify.GetProviderName(), + Recipient: recipientId, + CallFunc: notify.GetCallFunction(recipientIdentity), + Payload: recipientPayload, + ProviderName: notify.GetProviderName(), }) log.WithFields(logrus.Fields{ @@ -198,6 +180,77 @@ func hasMatchingProvider(notificationReq thandFunction.NotifierRequest, notifier return false } +// When a Temporal context is available, it dispatches a child workflow using +// the parent workflow's task queue (typically the agent identity), assuming +// the provider is registered on that worker. Otherwise it falls back to local +// provider execution. +func (t *thandTask) runNotifyTask( + ctx workflow.Context, + workflowTask sdkWorkflowsModel.WorkflowTaskSupport, + task notifyTask, +) notifyResult { + + // CRITICAL: YOU CANNOT CALL PROVIDERS directly from + // temporal. You must dispatch a child workflow to + // directly to servers that have the provider registered. This is because the workflow may be running on a different worker + + // from their we can figure out what task queues to execute on + + // Temporal path: dispatch a child workflow to the agent with this provider + if workflowTask.HasTemporalContext() { + wfName := models.CreateTemporalProviderWorkflowName( + task.ProviderName, models.TemporalNotifyWorkflowName) + + // Create unique child workflow ID using hash of composite identifier + // (provider + role + identity + tenant) to ensure uniqueness across + // different identities/tenants requesting the same role + childOpts := workflow.ChildWorkflowOptions{ + WorkflowID: models.CreateChildWorkflowID( + workflowTask.GetWorkflowID(), + models.TemporalNotifyWorkflowName, + task.ProviderName, + task.Recipient, + ), + TaskQueue: workflowTask.GetTaskQueue(), + } + ctx = workflow.WithChildOptions(ctx, childOpts) + + req := models.WorkflowNotifyRequest{ + Recipient: task.Recipient, + Payload: task.Payload, + } + err := workflow.ExecuteChildWorkflow(ctx, wfName, req).Get(ctx, nil) + if err != nil { + return notifyResult{ + Recipient: task.Recipient, + Error: err, + } + } + return notifyResult{ + Recipient: task.Recipient, + Error: nil, + } + } + + // Non-Temporal fallback: execute locally + providerCall, err := t.config.GetProviderByName(task.ProviderName) + if err != nil { + return notifyResult{ + Recipient: task.Recipient, + Error: fmt.Errorf("failed to get provider: %w", err), + } + } + + err = providerCall.SendNotification( + workflowTask.GetContext(), + task.Payload, + ) + return notifyResult{ + Recipient: task.Recipient, + Error: err, + } +} + // executeNotifyTemporalParallel executes notification tasks in parallel using Temporal func (t *thandTask) executeNotifyTemporalParallel( workflowTask *models.ElevateWorkflowTask, @@ -212,13 +265,6 @@ func (t *thandTask) executeNotifyTemporalParallel( temporalContext := workflowTask.GetTemporalContext() - ao := workflow.ActivityOptions{ - TaskQueue: workflowTask.GetTaskQueue(), - StartToCloseTimeout: 10 * time.Minute, - RetryPolicy: sdkWorkflowsRunner.DefaultRetryPolicy, - } - aoctx := workflow.WithActivityOptions(temporalContext, ao) - // Create channel and results slice results := make([]notifyResult, len(notifyTasks)) resultCh := workflow.NewChannel(temporalContext) @@ -226,40 +272,30 @@ func (t *thandTask) executeNotifyTemporalParallel( // Start all tasks in parallel using workflow.Go for i, task := range notifyTasks { taskIndex := i - notifyTask := task + taskForGoroutine := task logrus.WithFields(logrus.Fields{ "taskIndex": taskIndex, - "recipient": notifyTask.Recipient, - "provider": notifyTask.Provider, + "recipient": taskForGoroutine.Recipient, + "provider": taskForGoroutine.ProviderName, }).Info("Scheduling notify activity via workflow.Go") workflow.Go(temporalContext, func(ctx workflow.Context) { + log := workflow.GetLogger(ctx) + log.Info("Inside workflow.Go - about to execute activity", - "recipient", notifyTask.Recipient, + "recipient", taskForGoroutine.Recipient, "activityName", thandFunction.ThandNotifyFunction, ) - err := workflow.ExecuteActivity( - aoctx, - thandFunction.ThandNotifyFunction, - workflowTask, - taskName, - notifyTask.CallFunc, - notifyTask.Payload, - ).Get(ctx, nil) - - log.Info("Activity completed", - "recipient", notifyTask.Recipient, - "error", err, - ) + notifyResult := t.runNotifyTask(ctx, workflowTask, taskForGoroutine) // Send result through channel resultCh.Send(ctx, temporalNotifyResult{ Index: taskIndex, - Recipient: notifyTask.Recipient, - Err: err, + Recipient: notifyResult.Recipient, + Err: notifyResult.Error, }) }) } @@ -293,7 +329,7 @@ func (t *thandTask) executeNotifyGoParallel( defer wg.Done() // Get provider config - provider, err := t.config.GetProviderByName(notifyTask.Provider) + provider, err := t.config.GetProviderByName(notifyTask.ProviderName) if err != nil { results[index] = notifyResult{ Recipient: notifyTask.Recipient, diff --git a/internal/workflows/tasks/providers/thand/notify_impl.go b/internal/workflows/tasks/providers/thand/notify_impl.go index b747a134..5503f65f 100644 --- a/internal/workflows/tasks/providers/thand/notify_impl.go +++ b/internal/workflows/tasks/providers/thand/notify_impl.go @@ -13,6 +13,8 @@ import ( thandFunction "github.com/thand-io/agent/internal/workflows/functions/providers/thand" ) +const defaultLocalNotificationTitle = "Workflow Notification" + type NotifierImpl interface { GetProviderName() string GetRecipients() []string diff --git a/internal/workflows/tasks/providers/thand/revoke.go b/internal/workflows/tasks/providers/thand/revoke.go index 4c8d1f04..c41ae009 100644 --- a/internal/workflows/tasks/providers/thand/revoke.go +++ b/internal/workflows/tasks/providers/thand/revoke.go @@ -249,7 +249,7 @@ func (t *thandTask) runRevokeTask( // (provider + role + identity + tenant) to ensure uniqueness across // different identities/tenants requesting the same role childOpts := workflow.ChildWorkflowOptions{ - WorkflowID: models.CreateChildWorkflowID( + WorkflowID: models.CreateChildWorkflowIDFromRole( workflowTask.GetWorkflowID(), "revokeRole", task.ProviderName, @@ -397,10 +397,10 @@ func (t *thandTask) makeRevocationNotifications( recipientPayload := revokeNotifier.GetPayload(recipientIdentity) notifyTasks = append(notifyTasks, notifyTask{ - Recipient: recipientId, - CallFunc: revokeNotifier.GetCallFunction(recipientIdentity), - Payload: recipientPayload, - Provider: revokeNotifier.GetProviderName(), + Recipient: recipientId, + CallFunc: revokeNotifier.GetCallFunction(recipientIdentity), + Payload: recipientPayload, + ProviderName: revokeNotifier.GetProviderName(), }) log.WithFields(logrus.Fields{ @@ -416,7 +416,11 @@ func (t *thandTask) makeRevocationNotifications( var notifyResults []notifyResult if workflowTask.HasTemporalContext() { - notifyResults, err = t.executeNotifyTemporalParallel(workflowTask, fmt.Sprintf("%s.notify", taskName), notifyTasks) + notifyResults, err = t.executeNotifyTemporalParallel( + workflowTask, + fmt.Sprintf("%s.notify", taskName), + notifyTasks, + ) } else { notifyResults, err = t.executeNotifyGoParallel(workflowTask, notifyTasks) } diff --git a/test/go.mod b/test/go.mod index ff852588..f3f1ceba 100644 --- a/test/go.mod +++ b/test/go.mod @@ -23,9 +23,9 @@ require ( github.com/testcontainers/testcontainers-go v0.39.0 github.com/testcontainers/testcontainers-go/modules/localstack v0.39.0 github.com/thand-io/agent v0.0.0 - go.temporal.io/api v1.62.11 + go.temporal.io/api v1.62.12 go.temporal.io/sdk v1.43.0 - google.golang.org/grpc v1.81.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 ) @@ -37,7 +37,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/kms v1.31.0 // indirect - cloud.google.com/go/longrunning v0.13.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/secretmanager v1.20.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect @@ -49,7 +49,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.18.0 // indirect @@ -176,7 +176,7 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-slug v1.0.0 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect - github.com/hashicorp/go-tfe v1.105.0 // indirect + github.com/hashicorp/go-tfe v1.106.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/jsonapi v1.5.0 // indirect @@ -207,7 +207,7 @@ require ( github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect github.com/microsoftgraph/msgraph-sdk-go v1.98.0 // indirect - github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 // indirect + github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -224,8 +224,8 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nexus-rpc/sdk-go v0.6.0 // indirect - github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.12 // indirect + github.com/oasdiff/yaml v0.1.0 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/okta/okta-sdk-golang/v2 v2.20.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect @@ -238,7 +238,7 @@ require ( github.com/posthog/posthog-go v1.12.5 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/robfig/cron v1.2.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect @@ -249,7 +249,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/slack-go/slack v0.23.0 // indirect + github.com/slack-go/slack v0.23.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -257,7 +257,7 @@ require ( github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.13 // indirect @@ -300,22 +300,22 @@ require ( golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.278.0 // indirect - google.golang.org/genai v1.56.0 // indirect - google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect + google.golang.org/api v0.279.0 // indirect + google.golang.org/genai v1.57.0 // indirect + google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect gopkg.in/inf.v0 v0.9.1 // indirect gorm.io/driver/sqlite v1.6.0 // indirect gorm.io/gorm v1.31.1 // indirect - k8s.io/api v0.36.0 // indirect - k8s.io/apimachinery v0.36.0 // indirect - k8s.io/client-go v0.36.0 // indirect + k8s.io/api v0.36.1 // indirect + k8s.io/apimachinery v0.36.1 // indirect + k8s.io/client-go v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 // indirect + k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 // indirect k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/test/go.sum b/test/go.sum index 30743fe7..a2ab0795 100644 --- a/test/go.sum +++ b/test/go.sum @@ -10,8 +10,8 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= -cloud.google.com/go/longrunning v0.13.0 h1:dUfqF8y0bHOeZzF5+tKPZ6RBCeEEDOejvwGwENv/eEc= -cloud.google.com/go/longrunning v0.13.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/secretmanager v1.20.0 h1:GjE3NoyFXo7ipRPy26PMmg4oRX1Ra8fswH45r16rWV0= cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -44,8 +44,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1 h1:edShSHV3DV90+kt+CMaEXEzR9QF7wFrPJxVGz2blMIU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.1/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -359,8 +359,8 @@ github.com/hashicorp/go-slug v1.0.0 h1:aOwhQ1fIbyRAUdBDzXZK2LVmsFFQYuuvJhOM8X9XW github.com/hashicorp/go-slug v1.0.0/go.mod h1:Zxkkl8/LfXmhxZO3fLXQUCy3MVXAJK9pybY8WoDPgvs= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/go-tfe v1.105.0 h1:PXuWC9EWz+5sG/EC+tJO2i+lRamAe+A6tKt2XUImhPs= -github.com/hashicorp/go-tfe v1.105.0/go.mod h1:d8js2OmMnCq58gEh26mCS81nD8Aj7HmG6IO1b80gM78= +github.com/hashicorp/go-tfe v1.106.0 h1:qZdMWGEc2wSf87wo6bmamvWOzvhphyB78WJJyPMHhjo= +github.com/hashicorp/go-tfe v1.106.0/go.mod h1:d8js2OmMnCq58gEh26mCS81nD8Aj7HmG6IO1b80gM78= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= @@ -445,8 +445,8 @@ github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfd github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= github.com/microsoftgraph/msgraph-sdk-go v1.98.0 h1:95oYciFn5yTs4QBBntViNWTPaDVI1u5jJnpOUdVavWY= github.com/microsoftgraph/msgraph-sdk-go v1.98.0/go.mod h1:NMIFoKu7IVAerNRDjkZn7bxeiy55KZxQyneYqzH4+dQ= -github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= -github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1 h1:k3YIaJm57ufoEX0KdsEY4l1X9BAMxEqrwr4a7WMRDzY= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.1/go.mod h1:yNqPNhXee2w9cZzkJW5mL1utVMSInsQSo/TyEB5sup8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -483,10 +483,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nexus-rpc/sdk-go v0.6.0 h1:QRgnP2zTbxEbiyWG/aXH8uSC5LV/Mg1fqb19jb4DBlo= github.com/nexus-rpc/sdk-go v0.6.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= -github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= -github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= -github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg= +github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/okta/okta-sdk-golang/v2 v2.20.0 h1:EDKM+uOPfihOMNwgHMdno+NAsIfyXkVnoFAYVPay0YU= github.com/okta/okta-sdk-golang/v2 v2.20.0/go.mod h1:FMy5hN5G8Rd/VoS0XrfyPPhIfOVo78ZK7lvwiQRS2+U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -517,8 +517,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -543,8 +543,8 @@ github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68 h1:EW/NT+L github.com/simpleforce/simpleforce v0.0.0-20220429021116-acf4ac67ef68/go.mod h1:/trShGwjho17PsOcwG8PT6QoQ2HnZUooZX625+7qZ20= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/slack-go/slack v0.23.0 h1:PTMIHTKJNuA+jVh0BNuE52ntdA7FAxzSqWAdXl5rGa8= -github.com/slack-go/slack v0.23.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= +github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80= +github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -577,12 +577,10 @@ github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go/modules/localstack v0.39.0 h1:KI2cNWG8eDZKvswnz1NJhVZla0bo1WTRTFPMWDYzJ7w= github.com/testcontainers/testcontainers-go/modules/localstack v0.39.0/go.mod h1:RA935srUbMJu+owHmdRKF12/rl2aifgHqx/hSpPwcJ4= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= @@ -650,8 +648,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.temporal.io/api v1.62.11 h1:MWDaooDvOJCIRb1atqeZX2ErDPNTsNc3/mMEVEvvaVU= -go.temporal.io/api v1.62.11/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +go.temporal.io/api v1.62.12 h1:627rVnItegQmrszg1bH4vfyc/1uNo5qCereCNkvZefw= +go.temporal.io/api v1.62.12/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= go.temporal.io/sdk v1.43.0 h1:jHX/T2ZyBVjAtpQ/69NoMS6a+J0CpJAe+naqSB1gkvY= go.temporal.io/sdk v1.43.0/go.mod h1:w9XuJzV25JhnJqUzxJWJISpp5q/EyeCtRKHvhW3lIoQ= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -755,18 +753,18 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= -google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= -google.golang.org/genai v1.56.0 h1:IwWrg1K0cn1/WBiPno/dYr0Q6o75NeH/bh3G4JEFERE= -google.golang.org/genai v1.56.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8= -google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU= -google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA= -google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM= +google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -793,16 +791,16 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= -k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= -k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= -k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= -k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= -k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0 h1:1h+/yvsq5zm1mP/1wxmkRjTdrGpNDmumj+lsiJgwWTQ= -k8s.io/kube-openapi v0.0.0-20260507235316-19c3011e7fa0/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102 h1:xs2ux1MvyrOdfKwS3vuFWrGuLgDOHk6id975Twx2Jss= +k8s.io/kube-openapi v0.0.0-20260512234627-ef417d054102/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/test/integration/services/temporal_test.go b/test/integration/services/temporal_test.go index d114c1be..b30350a0 100644 --- a/test/integration/services/temporal_test.go +++ b/test/integration/services/temporal_test.go @@ -50,10 +50,10 @@ func temporalConfig(infra *testinfra.TestInfrastructure, disableVersioning bool) } // initAndRegister creates a TemporalClient, initialises it, and registers -// the echo workflow/activity on every worker. -func initAndRegister(t *testing.T, infra *testinfra.TestInfrastructure, cfg *models.TemporalConfig, identities ...string) *temporalService.TemporalClient { +// the echo workflow/activity on the worker. +func initAndRegister(t *testing.T, infra *testinfra.TestInfrastructure, cfg *models.TemporalConfig, taskQueue string) *temporalService.TemporalClient { t.Helper() - tc := temporalService.NewTemporalClient(cfg, nil, identities...) + tc := temporalService.NewTemporalClient(cfg, nil, taskQueue) require.NoError(t, tc.Initialize(), "Initialize should succeed") infra.RegisterCleanup(func() { _ = tc.Shutdown() }) @@ -162,33 +162,6 @@ func TestTemporalServiceVersioningEnabled(t *testing.T) { } } -// TestTemporalServiceMultiIdentityWorkers ensures multiple task-queue -// identities each get their own worker and can execute workflows independently. -func TestTemporalServiceMultiIdentityWorkers(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - ctx := context.Background() - infra := testinfra.SetupTemporalInfrastructure(t, ctx) - defer infra.Teardown() - - cfg := temporalConfig(infra, true) - tc := initAndRegister(t, infra, cfg, "queue-a", "queue-b") - - cl := tc.GetClient() - require.NotNil(t, cl) - - // Each queue should have a dedicated worker. - wA := tc.GetWorker("queue-a") - wB := tc.GetWorker("queue-b") - require.NotNil(t, wA, "worker for queue-a must exist") - require.NotNil(t, wB, "worker for queue-b must exist") - assert.Nil(t, tc.GetWorker("queue-c"), "non-existent queue returns nil") - - executeEchoWorkflow(t, cl, "queue-a", "msg-a") - executeEchoWorkflow(t, cl, "queue-b", "msg-b") -} - // TestTemporalServiceShutdownUnblocksGetClient confirms that calling // Shutdown before readiness unblocks a waiting GetClient caller. func TestTemporalServiceShutdownUnblocksGetClient(t *testing.T) { @@ -223,7 +196,7 @@ func TestTemporalServiceShutdownUnblocksGetClient(t *testing.T) { } // TestTemporalServiceNoIdentities confirms Initialize returns an error -// when no identities are provided. +// when no task queue is provided. func TestTemporalServiceNoIdentities(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -233,34 +206,10 @@ func TestTemporalServiceNoIdentities(t *testing.T) { defer infra.Teardown() cfg := temporalConfig(infra, true) - tc := temporalService.NewTemporalClient(cfg, nil) + tc := temporalService.NewTemporalClient(cfg, nil, "") err := tc.Initialize() - require.Error(t, err, "Initialize with zero identities should fail") - assert.Contains(t, err.Error(), "at least one identity") -} - -// TestTemporalServiceIdentityDedup verifies that duplicate identities are -// de-duplicated so only one worker is started per unique task queue. -func TestTemporalServiceIdentityDedup(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - ctx := context.Background() - infra := testinfra.SetupTemporalInfrastructure(t, ctx) - defer infra.Teardown() - - cfg := temporalConfig(infra, true) - tc := initAndRegister(t, infra, cfg, "dup-queue", "dup-queue", "dup-queue") - - cl := tc.GetClient() - require.NotNil(t, cl) - - // Only one worker should exist despite three identical identity args. - wAll := tc.GetWorker() - require.NotNil(t, wAll, "GetWorker must return a worker") - assert.Nil(t, tc.GetWorker("other"), "non-existent queue returns nil") - - executeEchoWorkflow(t, cl, "dup-queue", "dedup-msg") + require.Error(t, err, "Initialize with empty task queue should fail") + assert.Contains(t, err.Error(), "task queue") } // TestTemporalServiceNamespaceValidation exercises the live Temporal @@ -318,34 +267,7 @@ func TestTemporalServiceGetClientAccessorsAreSafe(t *testing.T) { _ = tc.HasClient() _ = tc.HasWorker() _ = tc.GetWorker() - _ = tc.GetWorker("race-queue") }() } wg.Wait() } - -// TestTemporalServiceMaxWorkersCap verifies that NewTemporalClient caps -// the number of workers at MaxWorkers. -func TestTemporalServiceMaxWorkersCap(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - ids := make([]string, temporalService.MaxWorkers+3) - for i := range ids { - ids[i] = "q-" + strconv.Itoa(i) - } - - cfg := &models.TemporalConfig{ - Host: "localhost", - Port: 7233, - Namespace: "default", - DisableVersioning: true, - } - tc := temporalService.NewTemporalClient(cfg, nil, ids...) - - // We can't call Initialize (no real server), but GetTaskQueue/GetIdentity - // reflect the first identity and the cap is applied internally. - assert.Equal(t, "q-0", tc.GetTaskQueue()) - assert.Equal(t, "q-0", tc.GetIdentity()) -}